From 071bf7f45f3c946226cb1aff6f8bc88556154363 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 25 Aug 2026 16:52:36 +0200 Subject: [PATCH 01/18] Make room for pplns: split the flag pool_mode was overloading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for #48. No PPLNS accounting yet — this is the mode plumbing it needs, and one refactor that has to happen first. `pps_enabled` was doing two unrelated jobs. It decided whether the coinbase pays the pool or the miner, and it decided whether a stratum username is a Thunder address or a Bitcoin one. Those happened to move together across the only two modes that existed, so one flag was enough: mode coinbase pays username solo the miner bitcoin pps-classic the pool thunder pplns-btc is the combination that breaks it. It pools the reward like PPS, so the coinbase pays the pool, and it pays out on L1 like solo, so the username is a Bitcoin address. No single flag expresses that, so it is now two: coinbase_pays_pool and username_is_thunder. A third came out of the same split. The accrual gate suspends crediting when network difficulty is below what PPS can safely price a share at, and it was reading pps_enabled. Rewriting it to read the gate pointer instead looked equivalent — main.c installs that pointer unconditionally, so it would have gated solo and both PPLNS modes, refusing miners from modes that never accrued anything to suspend. test_solo_is_never_gated caught it, which is exactly the defensive property it was written for. So the gate keys on pps_accrues, true only for pps-classic: it is the only mode that prices a share when it arrives and can therefore misprice one. PPLNS values a share in hindsight, out of a block actually found, so there is nothing to gate. The two pplns values are one knob rather than a mode plus a rail knob, per the decision on the issue: an operator runs Thunder or L1, never both, because the rail decides what a username is. One value makes the inconsistent configuration unrepresentable instead of merely rejected. `pool_mode = pplns` on its own is refused with a message naming the two real values, checked before the generic catch-all since it is the likely typo. Window size is pplns_window_diff_multiple, a multiple of current network difficulty rather than an absolute share count, so it self-scales across retargets — an absolute window silently changes meaning ~4x at each of the forknet retargets. Required > 0, and warned below 1.0, where a block pays out across less work than it took to find and rewards hopping. Still to come: the distribution step itself (read the window on a block maturing, credit pps_credits pro rata) and the L1 payout rail in the payout worker, which mirrors ThunderClient's small surface — balance, batch send, confirmation — against bitcoind's already-generic rpc_call. 379 stratum assertions (was 374), clean under ASan/UBSan, and proxy.conf.example still loads with no unknown key. Co-Authored-By: Claude Opus 5 (1M context) --- proxy.conf.example | 41 ++++++++++++- src/config.c | 45 +++++++++++++- src/config.h | 22 ++++++- src/main.c | 17 ++++-- src/stratum.c | 9 ++- src/stratum.h | 29 +++++++-- tests/test_stratum.c | 140 ++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 284 insertions(+), 19 deletions(-) diff --git a/proxy.conf.example b/proxy.conf.example index d20bbbd..9a52cb3 100644 --- a/proxy.conf.example +++ b/proxy.conf.example @@ -152,6 +152,29 @@ redis_reconnect_backoff_ms = 2000 # pool_mode controls the coinbase shape and the username validation: # solo default. Coinbase pays the miner (stratum username is a # BTC address) minus the operator fee. No PPS accrual. +# pplns-thunder / pplns-btc +# Pay Per Last N Shares. The coinbase pays pool_btc_address, as +# in pps-classic, but nothing is credited when a share arrives. +# When a block matures, the reward and its transaction fees are +# split across everyone whose shares fall inside the last-N +# window, in proportion to the difficulty each contributed. +# +# The difference that matters to an operator is who carries the +# variance. PPS guarantees a price per share, so a run of bad +# luck is the operator's problem and has to be absorbed by a +# reserve measured in block rewards. PPLNS never owes more than +# it has just been paid, so there is no reserve to size and +# operator ruin is not a failure mode. The miners carry the +# variance instead, which is why the fee is normally set lower +# than under PPS -- there is no risk premium to charge. +# +# The suffix picks the payout rail, and it is one knob rather +# than two because a pool runs one or the other: the rail +# decides what a stratum username IS. pplns-thunder takes +# Thunder addresses and pays over Thunder, exactly as +# pps-classic does. pplns-btc takes Bitcoin addresses and pays +# on L1 from the pool's wallet. +# # pps-classic Coinbase pays a pool BTC address (pool_btc_address below) # as a normal P2WPKH/P2PKH output. Miners authorize with a # Thunder address (base58 of a 20-byte hash) and accrue in @@ -160,9 +183,25 @@ redis_reconnect_backoff_ms = 2000 # drains that reserve to miners. pool_mode = solo -# pps-classic — required +# pps-classic and both pplns modes — required # pool_btc_address = REPLACE_WITH_POOL_BTC_ADDRESS +# pplns — the window, as a multiple of the CURRENT network difficulty. +# 2.0 means "the last two blocks' worth of expected work". +# +# A multiple rather than an absolute share count or difficulty sum, because it +# self-scales across retargets. An absolute window silently changes meaning +# every time the chain retargets: on a forknet moving 4x it becomes four times +# longer or shorter than the operator chose, with nothing in the config having +# changed to say so. +# +# Below 1.0 the window covers less work than a block is expected to take, so a +# block pays out across less work than it took to find. That rewards whoever +# happened to be connected at the moment over the work that actually produced +# the block -- which is the pool-hopping incentive PPLNS exists to remove. The +# proxy warns if you set it there. +# pplns_window_diff_multiple = 2.0 + # pps-classic — OPTIONAL rate override, sats credited per unit of share # difficulty. Leave it commented out and the proxy derives the rate from each # block template as (coinbasevalue / network_difficulty) * (1 - fee_bps/1e4). diff --git a/src/config.c b/src/config.c index db1b077..27bafd3 100644 --- a/src/config.c +++ b/src/config.c @@ -65,6 +65,7 @@ void proxy_config_defaults(proxy_config_t *cfg) { cfg->redis_reconnect_backoff_ms = 2000; snprintf(cfg->pool_mode, sizeof cfg->pool_mode, "%s", "solo"); + cfg->pplns_window_diff_multiple = 2.0; cfg->pool_btc_address[0] = '\0'; cfg->pps_sats_per_diff = 0.0; cfg->pps_min_network_difficulty = 0.0; @@ -259,6 +260,7 @@ int proxy_config_load(const char *path, proxy_config_t *cfg, else if (strcmp(k, "redis_reconnect_backoff_ms")== 0) cfg->redis_reconnect_backoff_ms = atoi(v); else if (strcmp(k, "pool_mode") == 0) copy_str(cfg->pool_mode, sizeof cfg->pool_mode, v); else if (strcmp(k, "pool_btc_address") == 0) copy_str(cfg->pool_btc_address, sizeof cfg->pool_btc_address, v); + else if (strcmp(k, "pplns_window_diff_multiple") == 0) cfg->pplns_window_diff_multiple = atof(v); else if (strcmp(k, "pps_sats_per_diff") == 0) cfg->pps_sats_per_diff = atof(v); else if (strcmp(k, "pps_min_network_difficulty") == 0) cfg->pps_min_network_difficulty = atof(v); else if (strcmp(k, "block_interval_sec") == 0) cfg->block_interval_sec = atoi(v); @@ -305,13 +307,52 @@ int proxy_config_load(const char *path, proxy_config_t *cfg, "that mode stranded the block reward. Use 'pps-classic'."); return -5; } + if (strcmp(cfg->pool_mode, "pplns") == 0) { + set_err(errbuf, errlen, + "config: 'pool_mode = pplns' does not say which rail pays. " + "Use 'pplns-thunder' or 'pplns-btc' — an operator runs one or " + "the other, and the rail decides what a stratum username is"); + return -5; + } + int mode_pplns = strcmp(cfg->pool_mode, "pplns-thunder") == 0 || + strcmp(cfg->pool_mode, "pplns-btc") == 0; if (strcmp(cfg->pool_mode, "solo") != 0 && - strcmp(cfg->pool_mode, "pps-classic") != 0) { + strcmp(cfg->pool_mode, "pps-classic") != 0 && + !mode_pplns) { set_err(errbuf, errlen, - "config: 'pool_mode' must be 'solo' or 'pps-classic', got '%s'", + "config: 'pool_mode' must be 'solo', 'pps-classic', " + "'pplns-thunder' or 'pplns-btc', got '%s'", cfg->pool_mode); return -5; } + if (mode_pplns) { + /* Same custody shape as pps-classic: the coinbase pays the pool, and + * the payout worker distributes. Without an address every rendered + * coinbase would fail at runtime instead of here. */ + if (cfg->pool_btc_address[0] == '\0') { + set_err(errbuf, errlen, + "config: 'pool_btc_address' is required when pool_mode=%s", + cfg->pool_mode); + return -9; + } + if (!(cfg->pplns_window_diff_multiple > 0.0)) { + set_err(errbuf, errlen, + "config: 'pplns_window_diff_multiple' must be > 0, got %g", + cfg->pplns_window_diff_multiple); + return -13; + } + /* A window shorter than a block's expected work pays a block out + * across less work than it took to find, which rewards whoever + * happened to be connected at the moment rather than the work that + * actually produced it — and is precisely the hopping incentive + * PPLNS exists to remove. */ + if (cfg->pplns_window_diff_multiple < 1.0) { + LOG_WARN("config: pplns_window_diff_multiple = %g is below 1.0 — " + "the window covers less work than a block is expected to " + "take, which rewards pool hopping", + cfg->pplns_window_diff_multiple); + } + } if (strcmp(cfg->pool_mode, "pps-classic") == 0) { /* pps_sats_per_diff is no longer required: unset means the rate is * derived per-template from coinbasevalue, network difficulty and diff --git a/src/config.h b/src/config.h index a58c55c..e2ca36d 100644 --- a/src/config.h +++ b/src/config.h @@ -84,8 +84,26 @@ typedef struct { * operator later batches that BTC into Thunder via the admin * dashboard's deposit action, and the payout worker drains the Thunder * reserve to miners. */ - char pool_mode[16]; /* "solo" | "pps-classic" */ - /* pps-classic: coinbase pays this BTC address (P2WPKH/P2PKH/P2SH) for + /* "solo" | "pps-classic" | "pplns-thunder" | "pplns-btc" + * + * The two pplns values are one knob rather than a mode plus a rail knob + * because an operator runs one or the other: a pool cannot pay some + * miners over Thunder and others on L1 from the same window, since the + * rail decides what a username even is. Encoding it as a single value + * makes the inconsistent configuration unrepresentable instead of + * merely rejected. */ + char pool_mode[24]; + /* PPLNS window size, as a multiple of the CURRENT network difficulty. + * 2.0 means "the last two blocks' worth of expected work". + * + * A multiple rather than an absolute figure because it self-scales + * across retargets. An absolute share count or difficulty sum silently + * changes meaning every time the chain retargets — on a forknet moving + * 4x that turns the window into something four times longer or shorter + * than the operator chose, without anything in the config changing. */ + double pplns_window_diff_multiple; /* default 2.0 */ + + /* pooled modes: coinbase pays this BTC address (P2WPKH/P2PKH/P2SH) for * the net-of-fee reward. Required when pool_mode = pps-classic; * ignored otherwise. */ char pool_btc_address[128]; diff --git a/src/main.c b/src/main.c index c9a8cdf..d897b32 100644 --- a/src/main.c +++ b/src/main.c @@ -1144,13 +1144,22 @@ int main(int argc, char **argv) { stcfg.listeners[i] = cfg.listeners[i]; } - /* PPS. pool_mode=pps-classic takes Thunder-address usernames, pays every - * coinbase into the pool's BTC wallet, and accrues per-share credits. */ - stcfg.pps_enabled = (strcmp(cfg.pool_mode, "pps-classic") == 0); + /* Which of the two things pool_mode decides applies here. See + * stratum.h — pplns-btc is the mode that makes them independent: it + * pools the reward (coinbase pays the pool) but pays out on L1 (the + * username is a Bitcoin address). */ + int mode_pps_classic = strcmp(cfg.pool_mode, "pps-classic") == 0; + int mode_pplns_thunder = strcmp(cfg.pool_mode, "pplns-thunder") == 0; + int mode_pplns_btc = strcmp(cfg.pool_mode, "pplns-btc") == 0; + int mode_pplns = mode_pplns_thunder || mode_pplns_btc; + + stcfg.pps_accrues = mode_pps_classic; + stcfg.coinbase_pays_pool = mode_pps_classic || mode_pplns; + stcfg.username_is_thunder = mode_pps_classic || mode_pplns_thunder; snprintf(stcfg.pool_btc_address, sizeof stcfg.pool_btc_address, "%s", cfg.pool_btc_address); - if (stcfg.pps_enabled) { + if (stcfg.coinbase_pays_pool) { /* Fail fast on a misconfigured pool_btc_address so we don't drop * every rendered job at runtime. */ uint8_t spk[64]; diff --git a/src/stratum.c b/src/stratum.c index 6ba8f0e..9be0377 100644 --- a/src/stratum.c +++ b/src/stratum.c @@ -482,7 +482,10 @@ static int buf_append_json_line(char **buf, size_t *len, cJSON *obj) { /* Is PPS accrual currently suspended? While it is, work handed to this pool * earns nothing, so the pool says so rather than banking it silently. */ static int pps_gated(const stratum_server_t *s) { - return s->cfg.pps_enabled && s->cfg.pps_refuse_shares_below_min && + /* Keyed on pps_accrues, not on the gate pointer: main.c installs that + * pointer for every mode, so testing it would suspend solo and PPLNS — + * modes that never accrued anything to suspend. */ + return s->cfg.pps_accrues && s->cfg.pps_refuse_shares_below_min && s->cfg.pps_gate && atomic_load_explicit(s->cfg.pps_gate, memory_order_relaxed) != 0; } @@ -615,7 +618,7 @@ static int conn_render_coinbase(stratum_server_t *s, stratum_conn_t *c, coinbase_parts_t parts = {0}; char err[256] = {0}; int rc; - if (s->cfg.pps_enabled) { + if (s->cfg.coinbase_pays_pool) { /* PPS-classic: every miner's coinbase is identical, paying the * pool's BTC wallet for the net-of-fee reward and the operator * address for the fee. The operator later moves accumulated BTC @@ -1235,7 +1238,7 @@ static int handle_authorize(stratum_server_t *s, stratum_conn_t *c, cJSON *id, c->payout_address[addr_len] = '\0'; char derr[128] = {0}; - if (s->cfg.pps_enabled) { + if (s->cfg.username_is_thunder) { /* Thunder address: 20-byte hash160 in plain base58. The * 's__' deposit-format wrapper is rejected (see * thunder.c). We don't need the decoded bytes here — the coinbase diff --git a/src/stratum.h b/src/stratum.h index 9b62f6d..dcda6af 100644 --- a/src/stratum.h +++ b/src/stratum.h @@ -146,14 +146,14 @@ typedef struct { stratum_listener_t listeners[STRATUM_MAX_LISTENERS]; int listener_count; /* Coinbase split — in solo mode each connection's coinbase pays the - * miner directly. In PPS mode (pps_enabled=1) every coinbase instead - * pays the single pool-owned pool_btc_address. In both modes + * miner directly. When the reward is pooled (coinbase_pays_pool=1) every + * coinbase instead pays the single pool-owned pool_btc_address. In both * (value * fee_bps / 10000) goes to operator_address as a BTC fee. */ char operator_address[128]; int fee_bps; char coinbase_tag[64]; - /* PPS (pool_mode=pps-classic). When pps_enabled = 1: + /* Pooled modes (pps-classic, pplns-*). When coinbase_pays_pool = 1: * - mining.authorize accepts Thunder addresses (base58 of 20-byte hash) * - the share observer's payout_address argument is the miner's * Thunder address (for PPS accrual), not a Bitcoin address. @@ -162,7 +162,28 @@ typedef struct { * fee. Deposits into Thunder happen off-band via the admin * dashboard, not in the coinbase. */ - int pps_enabled; + /* Two independent facts that pool_mode used to conflate under a single + * "is this PPS" flag. They are independent because pplns-btc is the mode + * that separates them: it pools the reward like PPS, so the coinbase pays + * the pool, while paying out over L1 like solo, so the username is a + * Bitcoin address. + * + * mode coinbase pays username + * solo the miner bitcoin + * pps-classic the pool thunder + * pplns-thunder the pool thunder + * pplns-btc the pool bitcoin + */ + int coinbase_pays_pool; + int username_is_thunder; + + /* Does this mode price a share when it arrives? Only pps-classic does. + * It is what the accrual gate suspends, so the gate must key on this and + * not on the gate pointer — main.c installs that pointer for every mode, + * and a mode with no per-share price has no accrual to suspend. Both + * PPLNS rails value a share only in hindsight, out of a block that was + * actually found, so there is nothing to misprice and nothing to gate. */ + int pps_accrues; char pool_btc_address[128]; /* pps-classic: coinbase spendable output */ /* Points at the proxy's PPS accrual gate — non-zero while network diff --git a/tests/test_stratum.c b/tests/test_stratum.c index 7a96302..375bd89 100644 --- a/tests/test_stratum.c +++ b/tests/test_stratum.c @@ -1511,7 +1511,8 @@ static void test_gated_pps_refuses_authorize_and_submits(void) { obs_t obs = {0}; _Atomic int gate = 1; stratum_cfg_t cfg = { .bind_port = 0, .max_conns = 1, .initial_diff = 1.0, - .pps_enabled = 1, .pps_gate = &gate, + .pps_accrues = 1, .username_is_thunder = 1, + .coinbase_pays_pool = 1, .pps_gate = &gate, .pps_refuse_shares_below_min = 1, .ctx = &obs, .on_share = on_share, .on_reject = on_reject, .on_block = on_block }; @@ -1573,7 +1574,8 @@ static void test_gate_can_be_disabled(void) { obs_t obs = {0}; _Atomic int gate = 1; stratum_cfg_t cfg = { .bind_port = 0, .max_conns = 1, .initial_diff = 1.0, - .pps_enabled = 1, .pps_gate = &gate, + .pps_accrues = 1, .username_is_thunder = 1, + .coinbase_pays_pool = 1, .pps_gate = &gate, .pps_refuse_shares_below_min = 0, .ctx = &obs, .on_share = on_share, .on_reject = on_reject, .on_block = on_block }; @@ -1600,7 +1602,7 @@ static void test_solo_is_never_gated(void) { obs_t obs = {0}; _Atomic int gate = 1; stratum_cfg_t cfg = { .bind_port = 0, .max_conns = 1, .initial_diff = 1.0, - .pps_enabled = 0, .pps_gate = &gate, + .pps_accrues = 0, .pps_gate = &gate, .pps_refuse_shares_below_min = 1, .ctx = &obs, .on_share = on_share, .on_reject = on_reject, .on_block = on_block }; @@ -2049,6 +2051,135 @@ static void test_authorized_miner_gets_the_long_idle_budget(void) { stratum_server_free(s2); } + +/* ---------------------------------------------------------------------- */ +/* PPLNS modes */ +/* ---------------------------------------------------------------------- */ + +#define THUNDER_ADDR "2sYBNmMJMMZHi6xasMcCPgNiYJ1z" + +/* Build a server in one of the PPLNS shapes. Both pool the reward, so both + * pay the coinbase to the pool; they differ only in what a username is. */ +static stratum_server_t *pplns_server(stratum_cfg_t *cfg, obs_t *obs, + _Atomic int *gate, int username_thunder) { + *cfg = (stratum_cfg_t){ .bind_port = 0, .max_conns = 1, .initial_diff = 1.0, + /* the whole point: pooled reward, and for + * pplns-btc a Bitcoin username alongside it */ + .coinbase_pays_pool = 1, + .username_is_thunder = username_thunder, + .pps_accrues = 0, + .pps_gate = gate, + .pps_refuse_shares_below_min = 1, + .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"); + snprintf(cfg->pool_btc_address, sizeof cfg->pool_btc_address, "%s", TEST_ADDR); + stratum_server_t *s = NULL; + stratum_server_start(cfg, &s); + return s; +} + +/* pplns-btc is the mode that proves pool_mode was conflating two independent + * facts. It pools the reward like PPS — so the coinbase pays the pool — while + * paying out on L1 like solo, so the stratum username is a Bitcoin address. + * No previous mode needed that combination, and a single "is this PPS" flag + * could not express it. */ +static void test_pplns_btc_takes_a_bitcoin_username(void) { + obs_t obs = {0}; + _Atomic int gate = 0; + stratum_cfg_t cfg; + stratum_server_t *s = pplns_server(&cfg, &obs, &gate, /*thunder*/0); + + uint8_t net[32]; memset(net, 0xff, 32); + 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); + CHECK(stratum_conn_authorized_for_test(c) == 1); + free(out); + + /* And a Thunder address is not a Bitcoin address, so it is refused here + * even though pps-classic and pplns-thunder would take it. */ + stratum_conn_t *c2 = stratum_conn_new_for_test(s); + out = NULL; olen = 0; + stratum_handle_message(s, c2, "{\"id\":1,\"method\":\"mining.subscribe\",\"params\":[]}", + &out, &olen); free(out); out = NULL; olen = 0; + stratum_handle_message(s, c2, + "{\"id\":2,\"method\":\"mining.authorize\"," + "\"params\":[\"" THUNDER_ADDR "\",\"x\"]}", &out, &olen); + CHECK(stratum_conn_authorized_for_test(c2) == 0); + free(out); + + stratum_conn_free_for_test(c); + stratum_conn_free_for_test(c2); + stratum_server_free(s); +} + +/* pplns-thunder is pps-classic's username rule with PPLNS accounting. */ +static void test_pplns_thunder_takes_a_thunder_username(void) { + obs_t obs = {0}; + _Atomic int gate = 0; + stratum_cfg_t cfg; + stratum_server_t *s = pplns_server(&cfg, &obs, &gate, /*thunder*/1); + + uint8_t net[32]; memset(net, 0xff, 32); + 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\":[\"" THUNDER_ADDR "\",\"x\"]}", &out, &olen); + CHECK(stratum_conn_authorized_for_test(c) == 1); + free(out); + + stratum_conn_free_for_test(c); + stratum_server_free(s); +} + +/* The accrual gate exists because pps-classic prices a share the moment it + * arrives, and on a trivially easy chain that price is wrong. PPLNS prices a + * share only in hindsight, out of a block actually found, so there is nothing + * to misprice and nothing to suspend. + * + * Worth pinning explicitly because main.c installs the gate pointer for every + * mode, so anything keying on the pointer rather than on "does this mode + * accrue" silently suspends a pool that was never accruing — refusing miners + * from a mode that had no exposure in the first place. */ +static void test_pplns_is_never_gated(void) { + for (int thunder = 0; thunder <= 1; ++thunder) { + obs_t obs = {0}; + _Atomic int gate = 1; /* fully gated, and irrelevant here */ + stratum_cfg_t cfg; + stratum_server_t *s = pplns_server(&cfg, &obs, &gate, thunder); + + uint8_t net[32]; memset(net, 0xff, 32); + 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; + char msg[256]; + snprintf(msg, sizeof msg, + "{\"id\":2,\"method\":\"mining.authorize\",\"params\":[\"%s\",\"x\"]}", + thunder ? THUNDER_ADDR : TEST_ADDR); + stratum_handle_message(s, c, msg, &out, &olen); + CHECK(stratum_conn_authorized_for_test(c) == 1); + free(out); + + stratum_conn_free_for_test(c); + stratum_server_free(s); + } +} + int main(void) { test_subscribe(); test_authorize_triggers_setdiff_notify(); @@ -2081,6 +2212,9 @@ int main(void) { test_gated_pps_refuses_authorize_and_submits(); test_gate_can_be_disabled(); test_solo_is_never_gated(); + test_pplns_btc_takes_a_bitcoin_username(); + test_pplns_thunder_takes_a_thunder_username(); + test_pplns_is_never_gated(); test_clean_jobs_only_on_a_new_tip(); test_promised_min_diff_survives_the_network_clamp(); test_vardiff_cannot_retarget_below_a_promised_floor(); From 02e19337ebecd27e643b0ed2892107f5f3f4f96d Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 25 Aug 2026 17:19:28 +0200 Subject: [PATCH 02/18] pplns: split a matured block across the window that produced it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The distribution step from #48, and the last piece that is rail-agnostic — pplns-thunder and pplns-btc both land here and differ only in what pays out afterwards. On every confirmation pass, any block that is confirmed, 100 deep, and not yet distributed is split across the shares that produced it: walk backwards from the block's own share accumulating difficulty until the window is full, then credit each worker its proportion of (reward + fees) net of fee_bps. Three decisions worth stating, because each has a wrong answer that looks fine. Maturity is 100 confirmations, not "confirmed". A coinbase output is unspendable until then, so crediting at confirmation creates a balance the pool genuinely cannot fund — the reserve requirement PPLNS exists to remove, reintroduced by accident. Waiting also deletes the orphan question rather than answering it: crediting is additive and there is no negative share, so a credit from a block that later turns out not to be ours cannot be taken back, and the reversal path that would otherwise have to exist simply never does. The window is snapshotted onto the block row when it is found, not recomputed when it is paid. Those moments are ~100 blocks apart and the chain can retarget in between; recomputing would pay a block out across a window its own miners never worked under, and would make the same block distribute differently depending on when the pass happened to run. Stored, the split is reproducible from the row alone. Transaction fees are included. Unlike pure PPS, PPLNS shares what the block actually earned — blocks_found already had reward_sats and fee_sats as separate columns, so this is summing two numbers that were already there. Two smaller ones. The share that crosses the window boundary is counted whole rather than split: the window is a rule for choosing which work gets paid, not a claim that exactly N difficulty was performed. And a pool younger than its own window pays the full reward across whatever work exists rather than scaling down — scaling down is arithmetically tidier but leaves a remainder with nowhere honest to go, since it is the miners' block and no third party has a claim on the difference. pplns_distributed is an exactly-once latch, and one transaction per block. Crediting being additive means a partial or repeated distribution is the one failure that cannot be fixed by running again, and that leaves no trace in the amounts themselves. Tested against a window with older work deliberately sitting behind it: carol mines 1000 difficulty outside the window and is paid nothing, which is the behaviour a naive "sum all shares" query gets wrong. Verified by mutation — removing the window bound and removing the maturity gate each fail the suite. Next: the payout rails. pplns-thunder needs none, the existing worker drains pps_credits already. pplns-btc needs an L1 client mirroring ThunderClient's surface. Co-Authored-By: Claude Opus 5 (1M context) --- src/main.c | 60 +++++++++++++- src/store.c | 199 ++++++++++++++++++++++++++++++++++++++++++++- src/store.h | 43 +++++++++- tests/test_store.c | 153 +++++++++++++++++++++++++++++++--- 4 files changed, 439 insertions(+), 16 deletions(-) diff --git a/src/main.c b/src/main.c index d897b32..28aa7bf 100644 --- a/src/main.c +++ b/src/main.c @@ -135,6 +135,12 @@ typedef struct { * lock per share would not be. Zero means "no accrual" (solo, or a * template we could not derive a rate from). */ _Atomic double pps_rate; + /* Network difficulty from the most recent template. PPLNS reads it when a + * block is found, to snapshot the window that block will later be + * distributed across — by the time it matures the chain may have + * retargeted, and recomputing then would pay it out across a window its + * own miners never worked under. */ + _Atomic double net_difficulty; /* Observed share-difficulty throughput, in difficulty units per second, * and the window it is accumulated over. This is the pool's own hashrate @@ -326,6 +332,10 @@ static void refresh_pps_rate(server_ctx_t *s, const bitcoind_template_t *t) { nbits_to_target(t->bits, target_be); } double net_diff = target_to_diff(target_be); + if (net_diff > 0.0 && isfinite(net_diff)) { + atomic_store_explicit(&s->net_difficulty, net_diff, + memory_order_relaxed); + } int64_t value = t->coinbase_value_sats; int overridden = s->cfg->pps_sats_per_diff > 0.0; @@ -592,10 +602,23 @@ static void on_block_found_cb(void *ctx, const char *worker_name, * Nothing here may write 'confirmed'. */ int status = accepted ? STORE_BLOCK_PENDING : STORE_BLOCK_REJECTED; if (s && s->store) { + /* Snapshot the PPLNS window for this block. Zero in every other mode, + * and zero here too if no template has been priced yet — a block with + * no window is skipped by the distributor rather than distributed + * across a window of nothing. */ + double window_diff = 0.0; + if (s->cfg && s->cfg->pplns_window_diff_multiple > 0.0 && + (strcmp(s->cfg->pool_mode, "pplns-thunder") == 0 || + strcmp(s->cfg->pool_mode, "pplns-btc") == 0)) { + double nd = atomic_load_explicit(&s->net_difficulty, + memory_order_relaxed); + if (nd > 0.0) window_diff = nd * s->cfg->pplns_window_diff_multiple; + } store_record_block(s->store, ts_ms, (int)height, block_hash, worker_name, finder_address, reward_sats, fee_sats, status, - accepted ? NULL : submit_error); + accepted ? NULL : submit_error, + window_diff); } /* pool:blocks carries solved blocks. A candidate the node refused is not * one, so it does not go out on that channel — the DB row is where a @@ -633,6 +656,16 @@ static void on_block_found_cb(void *ctx, const char *worker_name, * pool_meta.network_source distinguishes an authoritative answer from an * inferred one. Nothing here invents a verdict: a candidate that neither path * can speak to stays pending, and pending counts as nothing. */ +/* Confirmations a block needs before PPLNS will pay it out. + * + * 100 because that is when a coinbase output becomes spendable. Crediting + * earlier would create a balance the pool genuinely cannot fund yet — which + * is the reserve requirement PPLNS exists to remove, reintroduced by + * accident. It also makes orphan handling a non-question: a block 100 deep + * is not coming back out of the chain, so there is no credit to reverse and + * no need for a reversal path that would otherwise have to exist. */ +#define PPLNS_MATURITY_CONFS 100 + static void reconcile_blocks(server_ctx_t *s, int tip_height) { if (!s || !s->store || tip_height <= 0) return; @@ -680,6 +713,31 @@ static void reconcile_blocks(server_ctx_t *s, int tip_height) { LOG_DEBUG("block reconcile: confirmed=%d orphaned=%d pending=%d", confirmed, orphaned, pending); } + + /* PPLNS pays out here rather than at block-find time, because this is the + * only place that knows a block is still in the chain and how deep. A + * distribution is the last irreversible step in the pipeline: crediting is + * additive and there is no negative share, so anything credited from a + * block that later turns out not to be ours cannot be taken back. Running + * it off the confirmation pass, gated on maturity, means it only ever sees + * blocks that are 100 deep — by which point "still in the chain" has + * stopped being a question. */ + if (s->cfg && (strcmp(s->cfg->pool_mode, "pplns-thunder") == 0 || + strcmp(s->cfg->pool_mode, "pplns-btc") == 0)) { + int blocks = 0, workers = 0; + char derr[256] = {0}; + int rc = store_pplns_distribute(s->store, PPLNS_MATURITY_CONFS, + s->cfg->fee_bps, &blocks, &workers, + derr, sizeof derr); + if (rc < 0) { + LOG_WARN("pplns distribution failed: %s — nothing was credited, " + "the block stays undistributed and the next tip retries", + derr[0] ? derr : "unknown"); + } else if (blocks > 0) { + LOG_INFO("pplns: distributed %d matured block(s) across %d " + "worker credit(s)", blocks, workers); + } + } } /* ---------- tip watcher ---------- */ diff --git a/src/store.c b/src/store.c index 9d75976..745231c 100644 --- a/src/store.c +++ b/src/store.c @@ -99,6 +99,8 @@ static const char *SCHEMA_SQL_PARTS[] = { * distinction pool_meta.network_source draws. */ " status TEXT NOT NULL DEFAULT 'pending'," " confirmations INTEGER NOT NULL DEFAULT 0," + " pplns_window_diff REAL NOT NULL DEFAULT 0," + " pplns_distributed INTEGER NOT NULL DEFAULT 0," " submit_error TEXT," " checked_via TEXT" ");" @@ -343,6 +345,24 @@ static const char *MIGRATIONS_SQL[] = { "ALTER TABLE blocks_found ADD COLUMN submit_error TEXT", "ALTER TABLE blocks_found ADD COLUMN checked_via TEXT", "CREATE INDEX IF NOT EXISTS blocks_found_status_idx ON blocks_found(status)", + /* PPLNS distribution. + * + * pplns_window_diff is the window size in difficulty units, snapshotted + * when the block was found rather than recomputed at distribution time. + * The window is configured as a multiple of network difficulty, and a + * block is not distributed until it matures ~100 blocks later — by which + * time the chain may have retargeted. Recomputing then would pay the + * block out across a window its own miners never worked under, and would + * make the same block distribute differently depending on when the pass + * happened to run. Storing it makes the split deterministic and + * reproducible from the row alone. + * + * pplns_distributed is the exactly-once latch. Crediting is additive, so + * a second pass over the same block silently doubles everyone's balance — + * a failure that leaves no trace in the amounts themselves. */ + "ALTER TABLE blocks_found ADD COLUMN pplns_window_diff REAL NOT NULL DEFAULT 0", + "ALTER TABLE blocks_found ADD COLUMN pplns_distributed INTEGER NOT NULL DEFAULT 0", + "CREATE INDEX IF NOT EXISTS blocks_found_pplns_idx ON blocks_found(pplns_distributed, status)", }; /* Retries for one batch. busy_timeout (5s) bounds each attempt, so the worst @@ -371,6 +391,11 @@ typedef struct { int height; int64_t reward_sats; /* EV_BLOCK only */ int64_t fee_sats; /* EV_BLOCK only */ + /* EV_BLOCK only: the PPLNS window in difficulty units as it stood when + * this block was found. Snapshotted rather than recomputed at + * distribution time, which happens ~100 blocks later and possibly after + * a retarget. See the migration note on blocks_found. */ + double pplns_window_diff; uint8_t block_status; /* EV_BLOCK only: STORE_BLOCK_* */ int64_t delta_sats; /* EV_CREDIT only */ double rate_used; /* EV_SHARE only: multiplicand for delta_sats */ @@ -611,6 +636,7 @@ static void process_event(store_t *s, const event_t *ev) { SQLITE_TRANSIENT); else sqlite3_bind_null(s->st_insert_block, 9); + sqlite3_bind_double(s->st_insert_block, 10, ev->pplns_window_diff); if (sqlite3_step(s->st_insert_block) != SQLITE_DONE) { atomic_fetch_add(&s->pg_errors, 1); } else if (sqlite3_changes(s->db) > 0 && @@ -845,8 +871,8 @@ int store_open(const store_cfg_t *cfg, store_t **out) { static const char *Q_INS_BLOCK = "INSERT OR IGNORE INTO blocks_found " " (ts, height, hash, finder_id, finder_address, reward_sats, fee_sats," - " status, submit_error) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; + " status, submit_error, pplns_window_diff) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; /* Single-row upsert keyed on id=1. tip_observed_at is only set when * the tip actually changes (height or hash differ from the stored * row), so 'time since last tip change' stays meaningful across @@ -1195,7 +1221,8 @@ int store_record_block(store_t *s, uint64_t ts_ms, int height, const char *hash, const char *finder_name, const char *finder_address, int64_t reward_sats, int64_t fee_sats, - int status, const char *submit_error) + int status, const char *submit_error, + double pplns_window_diff) { if (!s || !hash) return -1; /* A coinbase height of zero is never valid. bitcoind_parse_template @@ -1214,6 +1241,7 @@ int store_record_block(store_t *s, uint64_t ts_ms, int height, ev.reward_sats = reward_sats; ev.fee_sats = fee_sats; ev.block_status = (uint8_t)status; + ev.pplns_window_diff = pplns_window_diff; if (submit_error) strncpy(ev.reason, submit_error, REASON_MAX - 1); strncpy(ev.hash, hash, HASH_STR_MAX - 1); @@ -1227,6 +1255,171 @@ int store_record_block(store_t *s, uint64_t ts_ms, int height, return 0; } +/* ---- PPLNS distribution ------------------------------------------------ */ + +int store_pplns_distribute(store_t *s, int maturity_confs, int fee_bps, + int *out_blocks, int *out_workers, + char *errbuf, size_t errlen) +{ + if (out_blocks) *out_blocks = 0; + if (out_workers) *out_workers = 0; + if (!s || !s->db) { + if (errbuf && errlen) snprintf(errbuf, errlen, "store not open"); + return -1; + } + if (maturity_confs < 0) maturity_confs = 0; + + /* Eligible blocks. All three conditions are load-bearing — see store.h. */ + static const char *Q_DUE = + "SELECT id, hash, " + " COALESCE(reward_sats,0) + COALESCE(fee_sats,0) AS gross, " + " pplns_window_diff " + " FROM blocks_found " + " WHERE status = 'confirmed' AND pplns_distributed = 0 " + " AND confirmations >= ? AND pplns_window_diff > 0 " + " ORDER BY height ASC"; + + /* The window: shares at or before this block's own share, newest first, + * taken until their difficulty sums to the window. + * + * The comparison is against the running total EXCLUDING the current row + * (running - difficulty < window), so the share that crosses the boundary + * is included whole rather than split. Splitting it would be arithmetically + * neater and would mean crediting a worker for a fraction of a share it + * either found or did not — the window is a rule for choosing which work + * gets paid, not a claim that exactly N difficulty was performed. + * + * A pool younger than its own window simply runs out of rows and pays the + * full reward across everything it has. */ + static const char *Q_WINDOW = + "WITH anchored AS (" + " SELECT id, worker_id, difficulty, " + " SUM(difficulty) OVER (ORDER BY id DESC ROWS UNBOUNDED PRECEDING) AS running " + " FROM shares " + " WHERE id <= (SELECT MAX(id) FROM shares WHERE block_hash = ?) " + ") " + "SELECT worker_id, SUM(difficulty) AS wd, " + " (SELECT SUM(difficulty) FROM anchored WHERE running - difficulty < ?2) AS total " + " FROM anchored " + " WHERE running - difficulty < ?2 " + " GROUP BY worker_id"; + + static const char *Q_CREDIT = + "INSERT INTO pps_credits (worker_id, accrued_sats, paid_sats, last_updated) " + "VALUES (?, ?, 0, ?) " + "ON CONFLICT(worker_id) DO UPDATE SET " + " accrued_sats = pps_credits.accrued_sats + excluded.accrued_sats, " + " last_updated = excluded.last_updated"; + + static const char *Q_MARK = + "UPDATE blocks_found SET pplns_distributed = 1 WHERE id = ?"; + + sqlite3_stmt *due = NULL; + if (sqlite3_prepare_v2(s->db, Q_DUE, -1, &due, NULL) != SQLITE_OK) { + if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); + return -1; + } + sqlite3_bind_int(due, 1, maturity_confs); + + int blocks = 0, workers = 0, rc_out = 0; + while (sqlite3_step(due) == SQLITE_ROW) { + sqlite3_int64 block_id = sqlite3_column_int64(due, 0); + const char *hash = (const char *)sqlite3_column_text(due, 1); + sqlite3_int64 gross = sqlite3_column_int64(due, 2); + double window = sqlite3_column_double(due, 3); + char hbuf[HASH_STR_MAX]; + snprintf(hbuf, sizeof hbuf, "%s", hash ? hash : ""); + + /* Net of the operator fee, the same basis points solo and PPS use. + * On PPLNS the fee is normally set lower: there is no variance being + * absorbed, so there is no risk premium to charge for. */ + int64_t payable = gross; + if (fee_bps > 0 && fee_bps <= 10000) { + payable = gross - (gross * (int64_t)fee_bps) / 10000; + } + if (payable <= 0) { + /* Nothing to share out, but the block is still settled: leaving + * the latch clear would re-examine it on every pass forever. */ + sqlite3_stmt *mk = NULL; + if (sqlite3_prepare_v2(s->db, Q_MARK, -1, &mk, NULL) == SQLITE_OK) { + sqlite3_bind_int64(mk, 1, block_id); + sqlite3_step(mk); + sqlite3_finalize(mk); + } + continue; + } + + /* One transaction per block: every credit for it lands or none does, + * and a failure leaves the latch clear so the next pass retries. A + * partial distribution is the one outcome that cannot be corrected by + * running again, because crediting is additive. */ + if (sqlite3_exec(s->db, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) { + rc_out = -1; + break; + } + + sqlite3_stmt *win = NULL, *cred = NULL, *mark = NULL; + int ok = sqlite3_prepare_v2(s->db, Q_WINDOW, -1, &win, NULL) == SQLITE_OK && + sqlite3_prepare_v2(s->db, Q_CREDIT, -1, &cred, NULL) == SQLITE_OK && + sqlite3_prepare_v2(s->db, Q_MARK, -1, &mark, NULL) == SQLITE_OK; + int credited_here = 0; + int64_t distributed = 0; + if (ok) { + sqlite3_bind_text (win, 1, hbuf, -1, SQLITE_TRANSIENT); + sqlite3_bind_double(win, 2, window); + while (sqlite3_step(win) == SQLITE_ROW) { + sqlite3_int64 wid = sqlite3_column_int64(win, 0); + double wd = sqlite3_column_double(win, 1); + double total = sqlite3_column_double(win, 2); + if (!(total > 0.0) || !(wd > 0.0)) continue; + /* Truncating division, so the sum of credits can fall a few + * sats short of payable. Rounding up instead would let it + * exceed the block, which is the direction that turns into an + * unfundable balance. */ + int64_t amt = (int64_t)((double)payable * (wd / total)); + if (amt <= 0) continue; + sqlite3_bind_int64(cred, 1, wid); + sqlite3_bind_int64(cred, 2, amt); + sqlite3_bind_int64(cred, 3, (sqlite3_int64)time(NULL)); + if (sqlite3_step(cred) != SQLITE_DONE) { ok = 0; } + sqlite3_reset(cred); + if (!ok) break; + distributed += amt; + credited_here++; + } + } + if (ok) { + sqlite3_bind_int64(mark, 1, block_id); + if (sqlite3_step(mark) != SQLITE_DONE) ok = 0; + } + sqlite3_finalize(win); + sqlite3_finalize(cred); + sqlite3_finalize(mark); + + if (ok) { + sqlite3_exec(s->db, "COMMIT", NULL, NULL, NULL); + blocks++; + workers += credited_here; + LOG_INFO("pplns: block %.16s… distributed %lld sats of %lld across " + "%d worker(s), window %.2f", + hbuf, (long long)distributed, (long long)payable, + credited_here, window); + } else { + sqlite3_exec(s->db, "ROLLBACK", NULL, NULL, NULL); + if (errbuf && errlen) + snprintf(errbuf, errlen, "distribute %.16s: %s", hbuf, + sqlite3_errmsg(s->db)); + rc_out = -1; + break; + } + } + sqlite3_finalize(due); + + if (out_blocks) *out_blocks = blocks; + if (out_workers) *out_workers = workers; + return rc_out < 0 ? rc_out : blocks; +} + int store_record_credit(store_t *s, const char *worker_name, const char *payout_address, uint64_t ts_ms, int64_t delta_sats) diff --git a/src/store.h b/src/store.h index 0e2e294..598f595 100644 --- a/src/store.h +++ b/src/store.h @@ -115,7 +115,48 @@ int store_record_block(store_t *s, uint64_t ts_ms, int height, const char *hash, const char *finder_name, const char *finder_address, int64_t reward_sats, int64_t fee_sats, - int status, const char *submit_error); + int status, const char *submit_error, + double pplns_window_diff); + +/* Distribute every matured, confirmed, not-yet-distributed block across the + * PPLNS window that produced it. Returns the number of blocks distributed, + * or negative on error. + * + * Three gates decide what is eligible, and all three matter: + * + * status = 'confirmed' the chain took it. A candidate submitblock refused, + * or one reorged out, pays nothing. + * confirmations >= maturity_confs + * a coinbase output is unspendable until 100 blocks + * deep. Crediting before that creates a balance the + * pool cannot fund, which is the reserve requirement + * PPLNS exists to avoid, reintroduced through the + * back door. Waiting also makes orphan reversal moot: + * 100 confirmations deep, there is nothing to undo. + * pplns_distributed = 0 crediting is additive, so a second pass over the + * same block doubles balances and leaves no trace in + * the numbers themselves. + * + * The window walks shares backwards from the block's own share, accumulating + * difficulty until it reaches the block row's pplns_window_diff. Each worker + * is credited + * + * (reward_sats + fee_sats) * (1 - fee_bps/10000) * worker_diff / window_diff + * + * Transaction fees are included deliberately: unlike pure PPS, PPLNS shares + * what the block actually earned rather than a subsidy-only estimate. + * + * A young pool whose entire history is shorter than the window pays the full + * reward across whatever work exists, rather than scaling down. Scaling down + * would be arithmetically tidier but leaves an undistributed remainder with + * nowhere honest to go — it is the miners' block, and there is no third party + * with a claim on the difference. + * + * Each block is distributed in one transaction: every credit for that block + * lands, or none does and the latch stays clear so the next pass retries. */ +int store_pplns_distribute(store_t *s, int maturity_confs, int fee_bps, + int *out_blocks, int *out_workers, + char *errbuf, size_t errlen); /* Record an accepted share with the miner's payout_address so the worker * row can be tagged. payout_address may be NULL (legacy/tests). The diff --git a/tests/test_store.c b/tests/test_store.c index 3f6df13..9f0cb8a 100644 --- a/tests/test_store.c +++ b/tests/test_store.c @@ -117,7 +117,7 @@ static void test_basic(void) { /* Block path */ rc = store_record_block(s, 9999, 12345, "abc123hash", "worker3", "bcrt1qexampleaddr", 4950000000LL, 50000000LL, - STORE_BLOCK_PENDING, NULL); + STORE_BLOCK_PENDING, NULL, 0.0); assert(rc == 0); rc = store_flush(s); assert(rc == 0); @@ -716,21 +716,21 @@ static void test_block_candidate_status(void) { * submit path is allowed to claim a block is in the chain. */ rc = store_record_block(s, 1000, 800001, "hash_accepted", "w1", "bcrt1qaddr", 5000000000LL, 0, - STORE_BLOCK_PENDING, NULL); + STORE_BLOCK_PENDING, NULL, 0.0); assert(rc == 0); /* Refused by the node: recorded so the refusal is visible, but as * 'rejected' — never counted, and carrying the node's reason. */ rc = store_record_block(s, 1001, 800001, "hash_rejected", "w1", "bcrt1qaddr", 5000000000LL, 0, - STORE_BLOCK_REJECTED, "inconclusive"); + STORE_BLOCK_REJECTED, "inconclusive", 0.0); assert(rc == 0); /* A coinbase height of zero cannot exist. Refused outright rather than * filed at a height no chain has. */ rc = store_record_block(s, 1002, 0, "hash_zero_height", "w1", "bcrt1qaddr", 5000000000LL, 0, - STORE_BLOCK_PENDING, NULL); + STORE_BLOCK_PENDING, NULL, 0.0); assert(rc != 0); rc = store_flush(s); @@ -799,13 +799,13 @@ static void test_reconcile_from_templates(void) { /* Two competing candidates at the same height — expected on a * low-difficulty chain, and both rows must survive. */ assert(store_record_block(s, 1000, 800001, "hash_win", "w1", "addr", - 5000000000LL, 0, STORE_BLOCK_PENDING, NULL) == 0); + 5000000000LL, 0, STORE_BLOCK_PENDING, NULL, 0.0) == 0); assert(store_record_block(s, 1001, 800001, "hash_lose", "w2", "addr", - 5000000000LL, 0, STORE_BLOCK_PENDING, NULL) == 0); + 5000000000LL, 0, STORE_BLOCK_PENDING, NULL, 0.0) == 0); /* A candidate whose next height was never observed. Unverifiable, so it * must stay pending — and pending is never revenue. */ assert(store_record_block(s, 1002, 800004, "hash_unseen", "w1", "addr", - 5000000000LL, 0, STORE_BLOCK_PENDING, NULL) == 0); + 5000000000LL, 0, STORE_BLOCK_PENDING, NULL, 0.0) == 0); assert(store_flush(s) == 0); store_template_t t = { @@ -886,12 +886,12 @@ static void test_block_hash_index_after_dedupe(void) { /* The same solution recorded twice — the stratum dedupe ring is in * memory, so a restart can do this. */ assert(store_record_block(s, 1000, 800001, "dup_hash", "w1", "addr", - 5000000000LL, 0, STORE_BLOCK_PENDING, NULL) == 0); + 5000000000LL, 0, STORE_BLOCK_PENDING, NULL, 0.0) == 0); assert(store_record_block(s, 1001, 800001, "dup_hash", "w1", "addr", - 5000000000LL, 0, STORE_BLOCK_PENDING, NULL) == 0); + 5000000000LL, 0, STORE_BLOCK_PENDING, NULL, 0.0) == 0); /* Distinct competing candidates must NOT be collapsed. */ assert(store_record_block(s, 1002, 800001, "other_hash", "w2", "addr", - 5000000000LL, 0, STORE_BLOCK_PENDING, NULL) == 0); + 5000000000LL, 0, STORE_BLOCK_PENDING, NULL, 0.0) == 0); assert(store_flush(s) == 0); sqlite3 *db = NULL; @@ -925,7 +925,7 @@ static void test_block_hash_index_after_dedupe(void) { /* And it now holds: a re-found hash cannot create a second row, and the * OR IGNORE means it does not fail the batch either. */ assert(store_record_block(s, 1003, 800001, "dup_hash", "w1", "addr", - 5000000000LL, 0, STORE_BLOCK_PENDING, NULL) == 0); + 5000000000LL, 0, STORE_BLOCK_PENDING, NULL, 0.0) == 0); assert(store_flush(s) == 0); assert(scalar_i64(db, "SELECT count(*) FROM blocks_found WHERE hash='dup_hash'") == 1); @@ -935,6 +935,135 @@ static void test_block_hash_index_after_dedupe(void) { printf(" ok test_block_hash_index_after_dedupe\n"); } +/* PPLNS: a matured block is split across the last-N window in proportion to + * difficulty, and exactly once. + * + * The window is 100 difficulty units. Shares are laid down so the boundary + * lands in a known place: alice contributes 10 shares of difficulty 5 (50), + * bob 10 of difficulty 5 (50) interleaved, and behind them sits a wall of + * older work by carol that must NOT be paid — it is outside the window, which + * is the entire point of PPLNS and the thing a naive "sum all shares" query + * gets wrong. */ +static void test_pplns_distributes_the_window(void) { + const char *path = fresh_db_path(); + store_cfg_t cfg = {0}; + snprintf(cfg.path, sizeof(cfg.path), "%s", path); + cfg.commit_window_ms = 20; + cfg.commit_max_shares = 500; + + store_t *s = NULL; + assert(store_open(&cfg, &s) == 0); + + /* Old work, well outside the window. */ + for (int i = 0; i < 40; ++i) { + assert(store_record_share_addr(s, "carol", "addr_c", + 1000ULL + (uint64_t)i, 25.0, + 0, NULL, 0, 0.0) == 0); + } + /* The window: alice and bob, 50 difficulty each. */ + for (int i = 0; i < 10; ++i) { + assert(store_record_share_addr(s, "alice", "addr_a", + 2000ULL + (uint64_t)i, 5.0, + 0, NULL, 0, 0.0) == 0); + assert(store_record_share_addr(s, "bob", "addr_b", + 2100ULL + (uint64_t)i, 5.0, + 0, NULL, 0, 0.0) == 0); + } + /* The block-finding share itself, by alice, difficulty 0 so it does not + * shift the split — this test is about the window, not about the finder + * getting anything extra. Under PPLNS the finder gets no premium. */ + assert(store_record_share_addr(s, "alice", "addr_a", 3000, 0.0, + 1, "blk_pplns", 0, 0.0) == 0); + /* window = 100 difficulty units, gross = 100000 sats. */ + assert(store_record_block(s, 3000, 800100, "blk_pplns", "alice", "addr_a", + 90000, 10000, STORE_BLOCK_PENDING, NULL, + 100.0) == 0); + assert(store_flush(s) == 0); + + /* Pending: nothing is owed yet. */ + int blocks = 0, workers = 0; + assert(store_pplns_distribute(s, 100, 0, &blocks, &workers, NULL, 0) == 0); + assert(blocks == 0); + + /* Confirmed but immature: still nothing. A coinbase output is unspendable + * until 100 deep, so crediting here would create a balance the pool + * cannot fund. */ + assert(store_set_block_status(s, "blk_pplns", STORE_BLOCK_CONFIRMED, + 6, "node") == 0); + assert(store_pplns_distribute(s, 100, 0, &blocks, &workers, NULL, 0) == 0); + assert(blocks == 0); + + /* Matured. */ + assert(store_set_block_status(s, "blk_pplns", STORE_BLOCK_CONFIRMED, + 100, "node") == 0); + assert(store_pplns_distribute(s, 100, 0, &blocks, &workers, NULL, 0) == 1); + assert(blocks == 1); + assert(workers == 2); + + sqlite3 *db = NULL; + assert(sqlite3_open(path, &db) == SQLITE_OK); + + int64_t a = scalar_i64(db, "SELECT accrued_sats FROM pps_credits WHERE worker_id =" + " (SELECT id FROM workers WHERE name='alice')"); + int64_t b = scalar_i64(db, "SELECT accrued_sats FROM pps_credits WHERE worker_id =" + " (SELECT id FROM workers WHERE name='bob')"); + int64_t c = scalar_i64(db, "SELECT COALESCE(SUM(accrued_sats),0) FROM pps_credits" + " WHERE worker_id =" + " (SELECT id FROM workers WHERE name='carol')"); + /* 50/50 of reward+fees. Fees are included deliberately: PPLNS shares what + * the block actually earned, not a subsidy-only estimate. */ + assert(a == 50000); + assert(b == 50000); + /* carol mined before the window and is paid nothing, however much work she + * did. That is what makes the window a window. */ + assert(c == 0); + + /* Exactly once. Crediting is additive, so a second pass would double every + * balance and leave no trace in the amounts themselves. */ + assert(store_pplns_distribute(s, 100, 0, &blocks, &workers, NULL, 0) == 0); + assert(blocks == 0); + assert(scalar_i64(db, "SELECT accrued_sats FROM pps_credits WHERE worker_id =" + " (SELECT id FROM workers WHERE name='alice')") == 50000); + + sqlite3_close(db); + store_close(s); + printf(" ok test_pplns_distributes_the_window\n"); +} + +/* The operator fee comes off the top, exactly as in solo and PPS. */ +static void test_pplns_takes_the_operator_fee(void) { + const char *path = fresh_db_path(); + store_cfg_t cfg = {0}; + snprintf(cfg.path, sizeof(cfg.path), "%s", path); + cfg.commit_window_ms = 20; + cfg.commit_max_shares = 500; + + store_t *s = NULL; + assert(store_open(&cfg, &s) == 0); + for (int i = 0; i < 10; ++i) { + assert(store_record_share_addr(s, "solo_miner", "addr_a", + 2000ULL + (uint64_t)i, 10.0, + 0, NULL, 0, 0.0) == 0); + } + assert(store_record_share_addr(s, "solo_miner", "addr_a", 3000, 0.0, + 1, "blk_fee", 0, 0.0) == 0); + assert(store_record_block(s, 3000, 800200, "blk_fee", "solo_miner", "addr_a", + 100000, 0, STORE_BLOCK_PENDING, NULL, 100.0) == 0); + assert(store_flush(s) == 0); + assert(store_set_block_status(s, "blk_fee", STORE_BLOCK_CONFIRMED, 100, "node") == 0); + + int blocks = 0, workers = 0; + /* 100 bps = 1%. */ + assert(store_pplns_distribute(s, 100, 100, &blocks, &workers, NULL, 0) == 1); + + sqlite3 *db = NULL; + assert(sqlite3_open(path, &db) == SQLITE_OK); + assert(scalar_i64(db, "SELECT accrued_sats FROM pps_credits") == 99000); + sqlite3_close(db); + store_close(s); + printf(" ok test_pplns_takes_the_operator_fee\n"); +} + int main(void) { log_init(2 /* WARN */); printf("running test_store...\n"); @@ -951,6 +1080,8 @@ int main(void) { test_block_candidate_status(); test_reconcile_from_templates(); test_block_hash_index_after_dedupe(); + test_pplns_distributes_the_window(); + test_pplns_takes_the_operator_fee(); cleanup_dbs(); printf("all tests passed\n"); return 0; From 8fc6ce15a908eecd64b247b10b858d792c596825 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 25 Aug 2026 19:13:00 +0200 Subject: [PATCH 03/18] pplns-btc: pay on L1 by asking the enforcer's wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last rail for #48, and much smaller than it was going to be. The plan before this was for the pool to track its own coinbase outpoints, serialise a BIP174 PSBT, and hand it to the operator to sign offline — several hundred lines of transaction construction whose bugs would be silent and expensive, in a binary that had never held a key or built a transaction. None of it needs to exist. bip300301_enforcer already ships a wallet, and its WalletService has SendTransaction: a destinations map, a fee rate, and it selects the inputs, signs and broadcasts itself. So pplns-btc is a client class of about a hundred lines and no new dependency. INSTALL.md had already been recommending an enforcer-owned pool_btc_address, which is exactly the arrangement this needs. The client mirrors ThunderClient's interface rather than inventing one — balance, transferBatchDetailed, getTransaction, walletUtxos, and mine() as a no-op because Bitcoin blocks arrive without being asked, where Thunder only advances when a mainchain block commits to it. The payout loop therefore never branches on which rail it is driving, and everything that makes a payout safe is written once and shared: the write-ahead payouts_in_flight row, one transaction per batch, and crediting paid_sats only on confirmation. Three things in it are load-bearing rather than defensive. destinations is keyed by address, and two rigs can authorize with the same payout address. Sending the list unmerged lets one entry overwrite the other, paying that miner once for two debts while the ledger marks both settled — a shortfall that balances perfectly on the pool's side and is visible only to the miner. The client sums by address first. Verified by mutation: replacing the sum with an assignment fails the suite. An unreachable enforcer reports unknown, never confirmed and never evicted. payout.js turns unknown into "block and ask a human", because "the node forgot it" and "it confirmed a while ago" look identical from here and guessing either way pays twice. The fee is a rate, not an amount. The enforcer selects the inputs, so it is the only party that knows the size of the transaction the fee applies to — there is no local estimator to drift out of date. PAYOUT_RAIL selects the rail and decides which of the two disjoint sets of environment variables is required, so a correctly configured L1 pool is not refused for lacking THUNDER_RPC_URL. The proxy logs what pplns-btc needs at startup — enforcer with --enable-wallet, pool_btc_address from that wallet, worker with PAYOUT_RAIL=btc — because otherwise the first sign of a misconfiguration is a payout failing 100 blocks after the block was found. 77 payout assertions (was 67), dashboard 135, C suites unchanged and clean under ASan/UBSan. Co-Authored-By: Claude Opus 5 (1M context) --- payout/README.md | 41 ++++++++ payout/index.js | 27 ++++- payout/lib/config.js | 37 ++++++- payout/lib/enforcer-rpc.js | 31 ++++++ payout/lib/enforcer-wallet.js | 151 ++++++++++++++++++++++++++++ payout/test/enforcer-wallet.test.js | 139 +++++++++++++++++++++++++ proxy.conf.example | 11 +- src/main.c | 13 +++ 8 files changed, 442 insertions(+), 8 deletions(-) create mode 100644 payout/lib/enforcer-rpc.js create mode 100644 payout/lib/enforcer-wallet.js create mode 100644 payout/test/enforcer-wallet.test.js diff --git a/payout/README.md b/payout/README.md index 1cb208e..2927ebd 100644 --- a/payout/README.md +++ b/payout/README.md @@ -10,6 +10,47 @@ only writer of `accrued_sats`; this worker is the only writer of `paid_sats`. SQLite WAL + a 5-second busy timeout keep them out of each other's way. +## Two rails + +The worker drains `pps_credits` and pays whoever is owed. **Which chain it +pays on** is `PAYOUT_RAIL`, and it must match the proxy's `pool_mode` — a pool +runs one or the other, never both, because the rail decides what a stratum +username even is. + +| `pool_mode` | `PAYOUT_RAIL` | username | pays via | +| --- | --- | --- | --- | +| `pps-classic`, `pplns-thunder` | `thunder` (default) | Thunder address | Thunder `create_transfer` | +| `pplns-btc` | `btc` | Bitcoin address | enforcer `WalletService/SendTransaction` | + +Everything that makes a payout safe is written once and shared: the +write-ahead `payouts_in_flight` row, one transaction per batch, and crediting +`paid_sats` only on confirmation. The two clients present the same interface, +so the loop never branches on which one it is driving. + +### The L1 rail + +The pool holds no keys and builds no transactions. The +`bip300301_enforcer` runs with `--enable-wallet`, the coinbase pays an address +from that wallet, and paying miners is one RPC — `SendTransaction` takes a +destinations map and a fee rate and does the input selection, signing and +broadcasting itself. + +```sh +PAYOUT_RAIL=btc +ENFORCER_RPC_ADDR=127.0.0.1:50051 # enforcer, with --enable-wallet +PAYOUT_FEE_RATE_SAT_VB=5 # a rate, not an absolute fee +ENFORCER_WALLET_PASSPHRASE=... # only for an encrypted wallet +``` + +The fee is a **rate**, not an amount, because the enforcer selects the inputs +and is therefore the only party that knows the size of the transaction the fee +applies to. There is no local estimator to drift out of date. + +Two miners can authorize with the same payout address from different rigs. +`destinations` is keyed by address, so the client sums them before sending — +an unmerged list would let one entry overwrite the other, paying that miner +once for two debts while the ledger marked both settled. + ## Run ``` diff --git a/payout/index.js b/payout/index.js index d7508e8..4de4391 100644 --- a/payout/index.js +++ b/payout/index.js @@ -19,6 +19,7 @@ import { loadConfig } from './lib/config.js'; import { openDb } from './lib/db.js'; import { ThunderClient } from './lib/thunder.js'; +import { EnforcerWalletClient } from './lib/enforcer-wallet.js'; import { startLoop, reportStuck, humanMs } from './lib/payout.js'; import { startAdminHttp } from './lib/admin-http.js'; @@ -39,11 +40,27 @@ log.info(` payout run every ${humanMs(cfg.intervalMs)} ` + log.info(` min_sats=${cfg.minSats} max_per_tick=${cfg.maxPerTick}`); const db = openDb(cfg.dbPath); -const thunder = new ThunderClient({ - url: cfg.rpcUrl, - user: cfg.rpcUser, - pass: cfg.rpcPass, -}); + +/* The two rails present the same interface — balance, transferBatchDetailed, + * getTransaction, walletUtxos — so the payout loop below never branches on + * which one it is driving. Everything that makes a payout safe (the + * write-ahead row, one transaction per batch, credit only on confirmation) + * is rail-independent and is written once. */ +const thunder = cfg.rail === 'btc' + ? new EnforcerWalletClient({ + addr: cfg.enforcerAddr, + feeRateSatPerVb: cfg.feeRateSatPerVb, + passphrase: cfg.walletPassphrase, + }) + : new ThunderClient({ + url: cfg.rpcUrl, + user: cfg.rpcUser, + pass: cfg.rpcPass, + }); +log.info(cfg.rail === 'btc' + ? ` rail=btc (L1 via enforcer wallet at ${cfg.enforcerAddr}, ` + + `fee ${cfg.feeRateSatPerVb} sat/vB)` + : ` rail=thunder (${cfg.rpcUrl})`); /* Surface any in-flight rows older than 5 minutes — they're a crashed * payout that needs manual reconciliation. We never auto-resolve them diff --git a/payout/lib/config.js b/payout/lib/config.js index becd9a5..3efe3f7 100644 --- a/payout/lib/config.js +++ b/payout/lib/config.js @@ -55,6 +55,25 @@ * THUNDER_FROM_ADDRESS pool reserve address to send from (must match * the dashboard's POOL_THUNDER_RESERVE_ADDRESS — * the wallet the operator deposits mined BTC into) + * + * L1 rail (pool_mode = pplns-btc). Selected by PAYOUT_RAIL; the Thunder + * variables above are then unused, and these are required instead: + * PAYOUT_RAIL 'thunder' (default) or 'btc'. Must match the + * proxy's pool_mode: pplns-btc pays on L1, every + * other mode pays over Thunder. A pool runs one or + * the other — the rail decides what a stratum + * username even is. + * ENFORCER_RPC_ADDR bip300301_enforcer ConnectRPC address, e.g. + * 127.0.0.1:50051. It must be running with + * --enable-wallet: the pool holds no keys and + * builds no transactions, it asks the enforcer's + * WalletService to send. + * PAYOUT_FEE_RATE_SAT_VB fee rate handed to the enforcer, which computes + * the fee from the transaction it actually builds + * (default 5). There is no local estimator to drift. + * ENFORCER_WALLET_PASSPHRASE + * optional; needed only for an encrypted wallet. + * Without it every spend fails at the wallet. */ function require_env(name) { @@ -67,12 +86,26 @@ function require_env(name) { } export function loadConfig() { + /* Which rail this worker drives. Read first: it decides which of the two + * disjoint sets of variables is required, and demanding Thunder's while + * running on L1 would refuse to start a correctly configured pool. */ + const rail = (process.env.PAYOUT_RAIL || 'thunder').toLowerCase(); + if (rail !== 'thunder' && rail !== 'btc') { + console.error(`fatal: PAYOUT_RAIL must be 'thunder' or 'btc', got '${rail}'`); + process.exit(2); + } + const l1 = rail === 'btc'; + return { + rail, dbPath: require_env('PAYOUT_DB_PATH'), - rpcUrl: require_env('THUNDER_RPC_URL'), + rpcUrl: l1 ? null : require_env('THUNDER_RPC_URL'), rpcUser: process.env.THUNDER_RPC_USER || null, rpcPass: process.env.THUNDER_RPC_PASS || null, - fromAddress: require_env('THUNDER_FROM_ADDRESS'), + fromAddress: l1 ? null : require_env('THUNDER_FROM_ADDRESS'), + enforcerAddr: l1 ? require_env('ENFORCER_RPC_ADDR') : null, + feeRateSatPerVb: parseInt(process.env.PAYOUT_FEE_RATE_SAT_VB || '5', 10), + walletPassphrase: process.env.ENFORCER_WALLET_PASSPHRASE || null, /* Daily batch cadence. Settlement and retry run on their own, * much shorter clocks — see nextDelayMs() in payout.js. */ intervalMs: parseInt(process.env.PAYOUT_INTERVAL_MS || '86400000', 10), diff --git a/payout/lib/enforcer-rpc.js b/payout/lib/enforcer-rpc.js new file mode 100644 index 0000000..85b07c2 --- /dev/null +++ b/payout/lib/enforcer-rpc.js @@ -0,0 +1,31 @@ +/* Minimal ConnectRPC client for the bip300301_enforcer. Unary RPCs only. */ + +export async function enforcerRpc(enforcerAddr, rpcPath, body, timeoutMs = 30_000) { + const base = /^https?:\/\//.test(enforcerAddr) ? enforcerAddr : `http://${enforcerAddr}`; + const ctl = new AbortController(); + const t = setTimeout(() => ctl.abort(), timeoutMs); + try { + const r = await fetch(`${base}/${rpcPath}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'connect-protocol-version': '1', + }, + body: JSON.stringify(body ?? {}), + signal: ctl.signal, + }); + const text = await r.text(); + let j; + try { j = JSON.parse(text); } + catch { + throw new Error(`enforcer ${rpcPath}: non-json response (http ${r.status}): ${text.slice(0, 200)}`); + } + if (!r.ok) { + const code = j.code || `http ${r.status}`; + throw new Error(`enforcer ${rpcPath}: ${code}${j.message ? `: ${j.message}` : ''}`); + } + return j; + } finally { + clearTimeout(t); + } +} diff --git a/payout/lib/enforcer-wallet.js b/payout/lib/enforcer-wallet.js new file mode 100644 index 0000000..901a0a8 --- /dev/null +++ b/payout/lib/enforcer-wallet.js @@ -0,0 +1,151 @@ +/* L1 Bitcoin payout rail, for pool_mode = pplns-btc. + * + * The pool holds no keys and builds no transactions. The bip300301_enforcer + * runs with --enable-wallet, the coinbase pays an address from that wallet + * (WalletService/CreateNewAddress), and paying miners is one RPC: + * WalletService/SendTransaction takes a destinations map and a fee rate and + * does the selecting, signing and broadcasting itself. + * + * That is the whole reason this file is short. An earlier design had the pool + * track its own coinbase outpoints, build a BIP174 PSBT, and hand it to the + * operator to sign offline — several hundred lines of transaction + * construction whose bugs would be silent and expensive. The enforcer already + * owns a wallet, so none of that has to exist here. + * + * The interface deliberately mirrors ThunderClient's, so payout.js does not + * care which rail it is driving: same at-most-once protocol, same + * payouts_in_flight rows, same settle-on-confirm. Only the drain differs. + */ + +import { enforcerRpc } from './enforcer-rpc.js'; + +const SVC = 'cusf.mainchain.v1.WalletService'; + +/* Sat amounts stay well inside 2^53 (all of Bitcoin is ~2.1e15), but the JSON + * layer is numbers, so refuse rather than round silently. */ +function safeNumber(v, what) { + const n = BigInt(v); + if (n > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error(`${what}: ${n} exceeds safe integer range`); + } + return Number(n); +} + +export class EnforcerWalletClient { + /* feeRateSatPerVb is passed straight through to the enforcer, which does + * the fee arithmetic. There is no local estimator to drift out of date. */ + constructor({ addr, feeRateSatPerVb = 5, passphrase = null, timeoutMs = 30_000 }) { + this.addr = addr; + this.feeRate = feeRateSatPerVb; + this.passphrase = passphrase; + this.timeoutMs = timeoutMs; + this._unlocked = false; + } + + async _call(method, body, timeoutMs = this.timeoutMs) { + return enforcerRpc(this.addr, `${SVC}/${method}`, body, timeoutMs); + } + + /* An encrypted wallet answers every spend with an error until it is + * unlocked, so do it once up front rather than discovering it mid-batch. + * Unlocking is idempotent and cheap; a wallet with no passphrase + * configured is assumed unencrypted and left alone. */ + async ensureUnlocked() { + if (this._unlocked || !this.passphrase) return; + await this._call('UnlockWallet', { password: this.passphrase }); + this._unlocked = true; + } + + /* Spendable balance in sats. The enforcer reports several buckets; only + * confirmed money can fund a payout — a coinbase output is not spendable + * until it is 100 deep, and counting it before then is how a pool + * promises what it cannot send. */ + async balance() { + const j = await this._call('GetBalance', {}); + const sats = j.confirmedSats ?? j.confirmed_sats ?? j.confirmed ?? 0; + return BigInt(Math.floor(Number(sats))); + } + + /* One transaction for the whole batch. Name and shape match + * ThunderClient.transferBatchDetailed so payout.js can hold either. + * + * The second argument is ignored: Thunder is quoted an absolute fee, + * whereas the enforcer takes a rate and computes the fee from the + * transaction it actually builds — which it can do and we cannot, since + * it is the one selecting the inputs. */ + async transferBatchDetailed(recipients, _feeSatsIgnored) { + if (!Array.isArray(recipients) || recipients.length === 0) { + throw new Error('transferBatch: no recipients'); + } + await this.ensureUnlocked(); + + /* Two miners can authorize with the same payout address from + * different rigs. destinations is keyed by address, so sending the + * list unmerged would let one entry overwrite the other and pay that + * miner once for two debts — while the ledger marked both settled. */ + const merged = new Map(); + for (const r of recipients) { + const sats = BigInt(r.sats); + if (sats <= 0n) throw new Error('transferBatch: non-positive amount'); + if (!r.address) throw new Error('transferBatch: missing address'); + merged.set(r.address, (merged.get(r.address) || 0n) + sats); + } + + const destinations = {}; + for (const [addr, sats] of merged) { + destinations[addr] = safeNumber(sats, `destination ${addr}`); + } + + const fail = (stage, err) => { err.stage = stage; return err; }; + let j; + try { + j = await this._call('SendTransaction', { + destinations, + fee_rate: { sat_per_vbyte: this.feeRate }, + }); + } catch (e) { throw fail('create', e); } + + const txid = j.txid?.hex ?? j.txid ?? j.txId ?? null; + if (!txid) { + throw fail('create', + new Error(`SendTransaction returned no txid: ${JSON.stringify(j).slice(0, 200)}`)); + } + return { txid, recipients: [...merged].map(([address, sats]) => ({ address, sats })) }; + } + + /* Settlement. Returns the shape settlementState() in payout.js expects: + * confirmed / known / error, where "unknown" must never be read as + * either confirmation or eviction. */ + async getTransaction(txid) { + let j; + try { + j = await this._call('ListTransactions', {}); + } catch (e) { + return { confirmed: false, known: false, error: e.message }; + } + const rows = j.transactions || j.txs || []; + const hit = rows.find(t => (t.txid?.hex ?? t.txid) === txid); + if (!hit) return { confirmed: false, known: false, error: null }; + const confs = Number(hit.confirmations ?? hit.confirmationHeight ?? 0); + return { confirmed: confs > 0, known: true, error: null }; + } + + /* payout.js cross-checks settlement against wallet outputs, because a + * node that has forgotten a transaction and one that confirmed it long + * ago look identical from getTransaction alone. */ + async walletUtxos() { + try { + const j = await this._call('ListUnspentOutputs', {}); + const rows = j.outputs || j.utxos || []; + return { ok: true, utxos: rows.map(u => ({ txid: u.txid?.hex ?? u.txid })) }; + } catch (e) { + return { ok: false, utxos: [], error: e.message }; + } + } + + /* Thunder only advances when a mainchain block commits to it, so its rail + * has to nudge mining along. Bitcoin blocks arrive without being asked. + * Present so payout.js can drive either rail without branching. */ + async mempool() { return { ok: true, txids: [] }; } + async mine() { return { ok: true, skipped: 'l1 needs no nudging' }; } +} diff --git a/payout/test/enforcer-wallet.test.js b/payout/test/enforcer-wallet.test.js new file mode 100644 index 0000000..e1150f9 --- /dev/null +++ b/payout/test/enforcer-wallet.test.js @@ -0,0 +1,139 @@ +/* The L1 payout rail. Nothing here talks to a real enforcer: the point is + * the shape of the request we send it and the shape of the answers we read, + * both of which are where a rail gets a payment wrong silently. */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { EnforcerWalletClient } from '../lib/enforcer-wallet.js'; + +/* Stand in for the ConnectRPC layer by recording what would be sent. */ +function stub(client, handlers) { + const calls = []; + client._call = async (method, body) => { + calls.push({ method, body }); + const h = handlers[method]; + if (typeof h === 'function') return h(body); + if (h instanceof Error) throw h; + return h ?? {}; + }; + return calls; +} + +test('a batch becomes one SendTransaction with a destinations map', async () => { + const c = new EnforcerWalletClient({ addr: '127.0.0.1:50051', feeRateSatPerVb: 7 }); + const calls = stub(c, { SendTransaction: { txid: { hex: 'deadbeef' } } }); + + const r = await c.transferBatchDetailed([ + { address: 'bcrt1qalice', sats: 1000n }, + { address: 'bcrt1qbob', sats: 2500n }, + ], 999 /* absolute fee — ignored on this rail */); + + assert.equal(calls.length, 1); + assert.equal(calls[0].method, 'SendTransaction'); + assert.deepEqual(calls[0].body.destinations, + { bcrt1qalice: 1000, bcrt1qbob: 2500 }); + /* A rate, not an absolute fee: the enforcer selects the inputs, so only + * it knows the size of the transaction the fee applies to. */ + assert.deepEqual(calls[0].body.fee_rate, { sat_per_vbyte: 7 }); + assert.equal(r.txid, 'deadbeef'); +}); + +test('two rigs on one payout address are added, not overwritten', async () => { + /* destinations is keyed by address. Sending the list unmerged lets the + * second entry replace the first, paying that miner once for two debts + * while the ledger marks both settled — a shortfall that balances + * perfectly on the pool's side and is invisible except to the miner. */ + const c = new EnforcerWalletClient({ addr: 'x' }); + const calls = stub(c, { SendTransaction: { txid: 'abc' } }); + + await c.transferBatchDetailed([ + { address: 'bcrt1qsame', sats: 1000n }, + { address: 'bcrt1qsame', sats: 250n }, + { address: 'bcrt1qother', sats: 7n }, + ]); + + assert.deepEqual(calls[0].body.destinations, + { bcrt1qsame: 1250, bcrt1qother: 7 }); +}); + +test('a reply with no txid is an error, not a silent success', async () => { + /* Returning undefined here would mark the batch broadcast with txid + * undefined, and settlement would then look for a transaction that + * cannot be found — stranding the batch in flight forever. */ + const c = new EnforcerWalletClient({ addr: 'x' }); + stub(c, { SendTransaction: { ok: true } }); + await assert.rejects(() => c.transferBatchDetailed([{ address: 'a', sats: 1n }]), + /no txid/); +}); + +test('non-positive and address-less recipients are refused before sending', async () => { + const c = new EnforcerWalletClient({ addr: 'x' }); + const calls = stub(c, { SendTransaction: { txid: 'z' } }); + await assert.rejects(() => c.transferBatchDetailed([{ address: 'a', sats: 0n }]), + /non-positive/); + await assert.rejects(() => c.transferBatchDetailed([{ address: '', sats: 5n }]), + /missing address/); + await assert.rejects(() => c.transferBatchDetailed([]), /no recipients/); + assert.equal(calls.length, 0); +}); + +test('an encrypted wallet is unlocked once, before the first spend', async () => { + const c = new EnforcerWalletClient({ addr: 'x', passphrase: 'hunter2' }); + const calls = stub(c, { UnlockWallet: {}, SendTransaction: { txid: 't' } }); + + await c.transferBatchDetailed([{ address: 'a', sats: 1n }]); + await c.transferBatchDetailed([{ address: 'b', sats: 1n }]); + + const unlocks = calls.filter(x => x.method === 'UnlockWallet'); + assert.equal(unlocks.length, 1); + assert.equal(unlocks[0].body.password, 'hunter2'); + /* and it happened before the first spend, not after it failed */ + assert.equal(calls[0].method, 'UnlockWallet'); +}); + +test('a wallet with no passphrase is never asked to unlock', async () => { + const c = new EnforcerWalletClient({ addr: 'x' }); + const calls = stub(c, { SendTransaction: { txid: 't' } }); + await c.transferBatchDetailed([{ address: 'a', sats: 1n }]); + assert.equal(calls.filter(x => x.method === 'UnlockWallet').length, 0); +}); + +test('balance counts confirmed sats only', async () => { + /* A coinbase output is not spendable until 100 deep. Counting anything + * else is how a pool promises what it cannot send. */ + const c = new EnforcerWalletClient({ addr: 'x' }); + stub(c, { GetBalance: { confirmedSats: 500000, pendingSats: 999999999 } }); + assert.equal(await c.balance(), 500000n); +}); + +test('an unreachable node is unknown, never confirmed and never evicted', async () => { + /* settlementState() in payout.js turns this into "unknown", which blocks + * and asks for a human. Reporting it as not-known-and-no-error would let + * the batch be re-queued and paid twice. */ + const c = new EnforcerWalletClient({ addr: 'x' }); + stub(c, { ListTransactions: new Error('connection refused') }); + const st = await c.getTransaction('abc'); + assert.equal(st.confirmed, false); + assert.equal(st.known, false); + assert.match(st.error, /connection refused/); +}); + +test('a transaction is settled only once it has a confirmation', async () => { + const c = new EnforcerWalletClient({ addr: 'x' }); + stub(c, { ListTransactions: { transactions: [ + { txid: { hex: 'aaa' }, confirmations: 0 }, + { txid: { hex: 'bbb' }, confirmations: 3 }, + ] } }); + assert.deepEqual(await c.getTransaction('bbb'), { confirmed: true, known: true, error: null }); + assert.deepEqual(await c.getTransaction('aaa'), { confirmed: false, known: true, error: null }); + assert.deepEqual(await c.getTransaction('ccc'), { confirmed: false, known: false, error: null }); +}); + +test('L1 needs no mining nudge, but answers the call payout.js makes', async () => { + /* Thunder advances only when a mainchain block commits to it, so its rail + * nudges. Bitcoin blocks arrive unasked. Answering rather than throwing is + * what lets the payout loop drive either rail without branching. */ + const c = new EnforcerWalletClient({ addr: 'x' }); + assert.equal((await c.mine()).ok, true); + assert.deepEqual(await c.mempool(), { ok: true, txids: [] }); +}); diff --git a/proxy.conf.example b/proxy.conf.example index 9a52cb3..f056346 100644 --- a/proxy.conf.example +++ b/proxy.conf.example @@ -173,7 +173,16 @@ redis_reconnect_backoff_ms = 2000 # decides what a stratum username IS. pplns-thunder takes # Thunder addresses and pays over Thunder, exactly as # pps-classic does. pplns-btc takes Bitcoin addresses and pays -# on L1 from the pool's wallet. +# on L1. +# +# pplns-btc needs the bip300301_enforcer running with +# --enable-wallet, and pool_btc_address set to an address from +# that wallet (WalletService/CreateNewAddress). The pool holds +# no keys and builds no transactions: the coinbase pays into +# the enforcer's wallet, and paying miners is one RPC to +# WalletService/SendTransaction, which selects the inputs, +# signs and broadcasts. The payout worker needs PAYOUT_RAIL=btc +# and ENFORCER_RPC_ADDR to match — see payout/README.md. # # pps-classic Coinbase pays a pool BTC address (pool_btc_address below) # as a normal P2WPKH/P2PKH output. Miners authorize with a diff --git a/src/main.c b/src/main.c index 28aa7bf..74a9bb7 100644 --- a/src/main.c +++ b/src/main.c @@ -1308,6 +1308,19 @@ int main(int argc, char **argv) { } bitcoind_template_free(tmpl); + /* The one thing pplns-btc needs that no other mode does, said at + * startup rather than discovered when the first payout fails 100 blocks + * later. The proxy cannot check it: the wallet belongs to the enforcer + * and the payout worker is a separate process. */ + if (strcmp(cfg.pool_mode, "pplns-btc") == 0) { + LOG_INFO("pplns-btc: miners are paid on L1. This requires " + "bip300301_enforcer running with --enable-wallet, " + "pool_btc_address (%s) being an address from that wallet, " + "and the payout worker started with PAYOUT_RAIL=btc. The " + "pool holds no keys — the enforcer signs and broadcasts.", + cfg.pool_btc_address); + } + LOG_INFO("stratum listening on %s:%d (difficulty from %g)", cfg.listen_addr, cfg.listen_port, cfg.initial_diff); for (int i = 0; i < cfg.listener_count; ++i) { From ad2256b0f74ee484fdba99f01123d061ef6c6ece Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 7 Sep 2026 09:55:26 +0200 Subject: [PATCH 04/18] Let the test suite build and run on macOS Neither of these is pplns work and neither is new here -- both reproduce on pristine main. They are in this branch because without them the branch cannot be tested on a Mac at all, and the pplns tests are the thing that needed running. Two separate problems. The suites did not compile. -D_POSIX_C_SOURCE=200809L is there to make clock_gettime and friends visible on glibc, but on Darwin the same macro works in the opposite direction: asking for a strict POSIX namespace hides everything BSD, and INADDR_LOOPBACK and MSG_DONTWAIT are BSD, not POSIX. 19 errors, all of them "use of undeclared identifier". _DARWIN_C_SOURCE puts them back, in the Darwin-only branch of the platform conditional, so it changes nothing on Linux. Once they compiled they hung, at the very first test, forever. The listener teardown breaks a thread out of accept() by calling shutdown() on the listening socket. That is a Linux behaviour -- there, shutdown() of a listening socket wakes a blocked accept(). On macOS and the BSDs it returns ENOTCONN and the accept() stays blocked, so the pthread_join right after it never returns. Every stratum test that starts a server hangs on the way out; CI is Ubuntu, so nothing ever noticed. The listener now waits in poll() with a 200ms timeout and re-tests the stop flag itself, rather than depending on the platform's shutdown() semantics to deliver the wakeup. The shutdown() call stays: where it works it still wakes the thread immediately and the timeout only bounds the worst case. A waiting connection wakes poll() straight away, so this is shutdown latency, not connection latency. 416 stratum assertions, the full make test, and make asan all pass on macOS now; the change is a no-op on Linux beyond the poll call itself. --- Makefile | 9 ++++++++- src/stratum.c | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 259bfdc..dd1ad61 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,14 @@ ifeq ($(UNAME_S),Darwin) BREW_PREFIX := /usr/local endif endif - PLATFORM_CFLAGS := -I$(BREW_PREFIX)/include \ + # _POSIX_C_SOURCE (below) is what makes clock_gettime and friends visible + # on glibc, but on Darwin the same macro works in reverse: asking for a + # strict POSIX namespace *hides* everything BSD, and INADDR_LOOPBACK and + # MSG_DONTWAIT are BSD, not POSIX. Without this the test suites do not + # compile on macOS at all. _DARWIN_C_SOURCE puts them back; it is a no-op + # anywhere else because this block is Darwin-only. + PLATFORM_CFLAGS := -D_DARWIN_C_SOURCE \ + -I$(BREW_PREFIX)/include \ -I$(BREW_PREFIX)/opt/sqlite/include \ -I$(BREW_PREFIX)/opt/curl/include \ -I$(BREW_PREFIX)/opt/hiredis/include diff --git a/src/stratum.c b/src/stratum.c index 3d3b464..f5ca7f1 100644 --- a/src/stratum.c +++ b/src/stratum.c @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -68,6 +69,11 @@ * work arrives a little late -- a proxy, rented hashrate, anything with a hop * in front of it -- is fine there and rejected here. */ #define RECENT_JOBS 16 +/* How long a listener thread waits in poll() before re-testing the stop flag. + * This is shutdown latency, not connection latency -- a waiting connection + * wakes poll() immediately. Small enough that stopping the pool feels instant, + * large enough that an idle listener is not spinning. */ +#define LISTEN_POLL_MS 200 /* ⚠️ The sweep is LAZY: retire_job() is its only caller and runs only when a * new job is pushed, so the real grace is this value PLUS the time to the next * job, and is unbounded if job production stalls. */ @@ -2451,6 +2457,22 @@ static void *listener_thread(void *arg) { struct stratum_listener_slot *ls = arg; stratum_server_t *s = ls->srv; while (!atomic_load(&s->stop)) { + /* Wait for a connection with a bounded timeout rather than blocking in + * accept() indefinitely, so the loop re-tests s->stop on its own. + * + * The teardown in stratum_server_stop calls shutdown() on the listening + * fd to break this thread out. That works on Linux, where shutdown() of + * a listening socket wakes a blocked accept(); on macOS and the BSDs it + * returns ENOTCONN and the accept() stays blocked forever, so the + * pthread_join that follows never returns and the process hangs at + * exit. Polling makes the wakeup a property of this loop instead of a + * property of the platform's shutdown() semantics. The shutdown() is + * still worth doing — where it works it wakes us immediately, and this + * timeout only bounds the worst case. */ + struct pollfd pfd = { .fd = ls->fd, .events = POLLIN, .revents = 0 }; + int pr = poll(&pfd, 1, LISTEN_POLL_MS); + if (pr <= 0) continue; /* timeout, or EINTR: re-test stop and retry */ + /* sockaddr_storage, not sockaddr_in: on an IPv6 or dual-stack listener * accept() writes a sockaddr_in6, which does not fit an IPv4 struct. * Passing the smaller one would have the kernel truncate the address From dc62e38212d55852ad39eddea13e0d5bb429f63b Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 7 Sep 2026 10:01:54 +0200 Subject: [PATCH 05/18] Keep schema.sql and store.c describing the same database blocks_found gained pplns_window_diff and pplns_distributed in store.c -- in the CREATE TABLE, in MIGRATIONS_SQL, and in an index -- and schema.sql was never updated to match. schema.sql is not documentation. scripts/deploy-to-server.sh seeds data/shares.db from it on every deploy that finds no database, tests/test_payout_regtest.sh builds the payout database from it, the dashboard's test fixtures are built from it, and INSTALL.md and README.md both tell operators to initialise from it. A pool deployed that way got a blocks_found without the two columns; the proxy's migrations repaired it on next start, so the damage was bounded, but anything reading that database before then -- or never opening it through store.c at all, which is exactly what the payout regtest does -- saw the old shape. The columns and the index are now in schema.sql, and the two paths agree exactly: same tables, same columns, checked rather than asserted by hand. Pinning that with a test, because this is the second time the two have drifted and neither drift was visible in a passing suite. The test builds one database from schema.sql and one from store_open(), then compares the column set of every table. Column sets rather than DDL text, so it does not fail on formatting or on constraints the two express differently, only on the thing that actually breaks: a column on one side and not the other. It refuses to skip when it cannot find schema.sql -- a parity check that quietly does nothing is how the drift got here. Verified by mutation both ways: dropping a column from schema.sql fails the test with a printed diff of the two column sets, and restoring it passes. Also covering the pplns distributor's second iteration, which nothing exercised. Every existing pplns test settles exactly one block per call, so the loop body had only ever run once. It opens a transaction, UPDATEs blocks_found, and commits while the outer SELECT over that same table is still stepping -- if committing mid-iteration were refused, or marking a row changed what the open cursor still returned, the second block would be skipped or paid twice. Two matured blocks in one pass now assert both get exactly their own window. C suites all green, payout 77, dashboard 135. --- schema.sql | 14 ++++ tests/test_store.c | 193 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) diff --git a/schema.sql b/schema.sql index 09e8bbe..f2d286d 100644 --- a/schema.sql +++ b/schema.sql @@ -77,11 +77,25 @@ CREATE TABLE IF NOT EXISTS blocks_found ( fee_sats INTEGER, status TEXT NOT NULL DEFAULT 'pending', confirmations INTEGER NOT NULL DEFAULT 0, + /* PPLNS distribution. Zero in every other mode. + * + * pplns_window_diff is the window size in difficulty units, snapshotted + * when the block was found rather than recomputed when it is paid out. + * Those moments are ~100 blocks apart and the chain can retarget between + * them, so recomputing would pay a block across a window its own miners + * never worked under. Stored, the split is reproducible from the row. + * + * pplns_distributed is the exactly-once latch. Crediting is additive, so a + * second pass over the same block doubles every balance and leaves no + * trace in the amounts themselves. */ + pplns_window_diff REAL NOT NULL DEFAULT 0, + pplns_distributed INTEGER NOT NULL DEFAULT 0, submit_error TEXT, checked_via TEXT ); CREATE INDEX IF NOT EXISTS blocks_found_ts_idx ON blocks_found(ts); CREATE INDEX IF NOT EXISTS blocks_found_status_idx ON blocks_found(status); +CREATE INDEX IF NOT EXISTS blocks_found_pplns_idx ON blocks_found(pplns_distributed, status); /* Single-row mirror of the upstream bitcoind tip the proxy is currently * mining on. Written by the proxy's tip watcher on every successful diff --git a/tests/test_store.c b/tests/test_store.c index 03b62c1..fce6125 100644 --- a/tests/test_store.c +++ b/tests/test_store.c @@ -156,6 +156,123 @@ static void test_open_upgrades_a_pre_status_database(void) { printf(" ok test_open_upgrades_a_pre_status_database\n"); } +/* schema.sql and the schema store.c creates must describe the same database. + * + * Both are real initialisation paths, not one canonical source and one copy. + * scripts/deploy-to-server.sh seeds data/shares.db from schema.sql, so does + * tests/test_payout_regtest.sh, the dashboard tests build their fixtures from + * it, and INSTALL.md and README.md both tell operators to. store.c builds the + * other one, for a pool that starts with no database at all. + * + * They drift silently. A column added to store.c's CREATE and its migrations + * but not to schema.sql leaves a deploy-seeded pool with a table the proxy + * only repairs on its next start -- and any tool reading that database before + * then, or never opening it through store.c at all, sees the old shape. That + * is exactly what happened to the two pplns columns. + * + * Comparing the column sets rather than the DDL text keeps this from failing + * on formatting, comments, or constraints the two express differently, while + * still catching the thing that actually breaks: a column on one side and not + * the other. */ +static int cmp_str(const void *a, const void *b) { + return strcmp(*(const char *const *)a, *(const char *const *)b); +} + +/* "table:col,col;table:col,col;" with tables and columns both sorted, so the + * comparison does not depend on declaration order. */ +static void canonical_schema(sqlite3 *db, char *out, size_t cap) { + out[0] = '\0'; + size_t used = 0; + sqlite3_stmt *tq = NULL; + assert(sqlite3_prepare_v2(db, + "SELECT name FROM sqlite_master WHERE type='table' " + " AND name NOT LIKE 'sqlite_%' ORDER BY name", -1, &tq, NULL) == SQLITE_OK); + while (sqlite3_step(tq) == SQLITE_ROW) { + const char *tbl = (const char *)sqlite3_column_text(tq, 0); + if (!tbl) continue; + char cols[64][64]; + char *colp[64]; + int nc = 0; + char q[256]; + snprintf(q, sizeof q, "PRAGMA table_info(%s)", tbl); + sqlite3_stmt *cq = NULL; + assert(sqlite3_prepare_v2(db, q, -1, &cq, NULL) == SQLITE_OK); + while (nc < 64 && sqlite3_step(cq) == SQLITE_ROW) { + const char *cn = (const char *)sqlite3_column_text(cq, 1); + if (!cn) continue; + snprintf(cols[nc], sizeof cols[nc], "%s", cn); + colp[nc] = cols[nc]; + nc++; + } + sqlite3_finalize(cq); + qsort(colp, (size_t)nc, sizeof colp[0], cmp_str); + used += (size_t)snprintf(out + used, cap - used, "%s:", tbl); + for (int i = 0; i < nc && used < cap; ++i) + used += (size_t)snprintf(out + used, cap - used, "%s%s", + colp[i], i + 1 < nc ? "," : ""); + if (used < cap) used += (size_t)snprintf(out + used, cap - used, ";\n"); + } + sqlite3_finalize(tq); +} + +static void test_schema_sql_matches_store_schema(void) { + /* make runs the suites from the repo root; try one level up too so a + * direct ./build/test_store from tests/ still works. Never silently skip: + * a skipped parity check is how the drift got here in the first place. */ + const char *candidates[] = { "schema.sql", "../schema.sql" }; + FILE *f = NULL; + for (size_t i = 0; i < sizeof candidates / sizeof candidates[0]; ++i) { + f = fopen(candidates[i], "rb"); + if (f) break; + } + assert(f && "schema.sql not found - run the suite from the repo root"); + fseek(f, 0, SEEK_END); + long len = ftell(f); + fseek(f, 0, SEEK_SET); + assert(len > 0); + char *sql = malloc((size_t)len + 1); + assert(sql); + assert(fread(sql, 1, (size_t)len, f) == (size_t)len); + sql[len] = '\0'; + fclose(f); + + /* One database from schema.sql... */ + const char *path_a = fresh_db_path(); + sqlite3 *a = NULL; + assert(sqlite3_open(path_a, &a) == SQLITE_OK); + char *errm = NULL; + int rc = sqlite3_exec(a, sql, NULL, NULL, &errm); + assert(rc == SQLITE_OK && "schema.sql must apply cleanly"); + sqlite3_free(errm); + free(sql); + + /* ...and one from store_open, which is what a fresh pool gets. */ + const char *path_b = fresh_db_path(); + store_cfg_t cfg = {0}; + snprintf(cfg.path, sizeof(cfg.path), "%s", path_b); + cfg.commit_window_ms = 20; + cfg.commit_max_shares = 100; + store_t *s = NULL; + assert(store_open(&cfg, &s) == 0); + sqlite3 *b = NULL; + assert(sqlite3_open(path_b, &b) == SQLITE_OK); + + char sa[8192], sb[8192]; + canonical_schema(a, sa, sizeof sa); + canonical_schema(b, sb, sizeof sb); + if (strcmp(sa, sb) != 0) { + fprintf(stderr, "schema.sql and store.c disagree.\n" + "--- schema.sql ---\n%s\n--- store.c ---\n%s\n", sa, sb); + } + assert(strcmp(sa, sb) == 0 && + "schema.sql and store.c must create the same columns"); + + sqlite3_close(a); + sqlite3_close(b); + store_close(s); + printf(" ok test_schema_sql_matches_store_schema\n"); +} + static void test_basic(void) { const char *path = fresh_db_path(); store_cfg_t cfg = {0}; @@ -1108,6 +1225,80 @@ static void test_pplns_distributes_the_window(void) { printf(" ok test_pplns_distributes_the_window\n"); } +/* Two matured blocks settled by a single pass. + * + * Every other pplns test distributes exactly one block per call, which never + * exercises the loop's second iteration. That iteration is where the shape of + * store_pplns_distribute matters: the outer SELECT over blocks_found is still + * stepping while the body opens a transaction, UPDATEs the very table that + * SELECT is reading, and commits it. If committing mid-iteration were refused, + * or if marking a row changed what the open cursor still had to return, the + * first block would settle and the second would be skipped or double-paid -- + * and with crediting additive, double-paying is the failure that cannot be + * undone by running again. */ +static void test_pplns_distributes_two_blocks_in_one_pass(void) { + const char *path = fresh_db_path(); + store_cfg_t cfg = {0}; + snprintf(cfg.path, sizeof(cfg.path), "%s", path); + cfg.commit_window_ms = 20; + cfg.commit_max_shares = 500; + + store_t *s = NULL; + assert(store_open(&cfg, &s) == 0); + + /* Block one's window: alice, 50 difficulty. */ + for (int i = 0; i < 10; ++i) { + assert(store_record_share_addr(s, "alice", "addr_a", + 1000ULL + (uint64_t)i, 5.0, + 0, NULL, 0, 0.0) == 0); + } + assert(store_record_share_addr(s, "alice", "addr_a", 1100, 0.0, + 1, "blk_one", 0, 0.0) == 0); + /* Block two's window: bob, 50 difficulty, entirely after block one's. */ + for (int i = 0; i < 10; ++i) { + assert(store_record_share_addr(s, "bob", "addr_b", + 2000ULL + (uint64_t)i, 5.0, + 0, NULL, 0, 0.0) == 0); + } + assert(store_record_share_addr(s, "bob", "addr_b", 2100, 0.0, + 1, "blk_two", 0, 0.0) == 0); + + assert(store_record_block(s, 1100, 800300, "blk_one", "alice", "addr_a", + 100000, 0, STORE_BLOCK_PENDING, NULL, 50.0) == 0); + assert(store_record_block(s, 2100, 800301, "blk_two", "bob", "addr_b", + 100000, 0, STORE_BLOCK_PENDING, NULL, 50.0) == 0); + assert(store_flush(s) == 0); + + assert(store_set_block_status(s, "blk_one", STORE_BLOCK_CONFIRMED, + 100, "node") == 0); + assert(store_set_block_status(s, "blk_two", STORE_BLOCK_CONFIRMED, + 100, "node") == 0); + + /* Both, in one call. */ + int blocks = 0, workers = 0; + char err[256] = {0}; + assert(store_pplns_distribute(s, 100, 0, &blocks, &workers, err, sizeof err) == 2); + assert(blocks == 2); + assert(workers == 2); + + sqlite3 *db = NULL; + assert(sqlite3_open(path, &db) == SQLITE_OK); + assert(scalar_i64(db, "SELECT accrued_sats FROM pps_credits WHERE worker_id =" + " (SELECT id FROM workers WHERE name='alice')") == 100000); + assert(scalar_i64(db, "SELECT accrued_sats FROM pps_credits WHERE worker_id =" + " (SELECT id FROM workers WHERE name='bob')") == 100000); + /* Both latched, so a second pass is a no-op rather than a second payment. */ + assert(scalar_i64(db, "SELECT COUNT(*) FROM blocks_found" + " WHERE pplns_distributed = 1") == 2); + assert(store_pplns_distribute(s, 100, 0, &blocks, &workers, NULL, 0) == 0); + assert(blocks == 0); + assert(scalar_i64(db, "SELECT SUM(accrued_sats) FROM pps_credits") == 200000); + + sqlite3_close(db); + store_close(s); + printf(" ok test_pplns_distributes_two_blocks_in_one_pass\n"); +} + /* The operator fee comes off the top, exactly as in solo and PPS. */ static void test_pplns_takes_the_operator_fee(void) { const char *path = fresh_db_path(); @@ -1161,6 +1352,8 @@ int main(void) { test_open_upgrades_a_pre_status_database(); test_pplns_distributes_the_window(); test_pplns_takes_the_operator_fee(); + test_pplns_distributes_two_blocks_in_one_pass(); + test_schema_sql_matches_store_schema(); cleanup_dbs(); printf("all tests passed\n"); return 0; From 27f8b94d1ca3e9c524442fc4ebaeae70676f8642 Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 7 Sep 2026 10:04:11 +0200 Subject: [PATCH 06/18] Stop the dashboard describing a pplns pool as solo The identity strip knew two modes. pps-classic got its own description and everything else fell through to solo's: "Each block's coinbase pays the miner who found it, directly. No share credit accrues between blocks." Both halves of that are false under pplns. The coinbase pays the pool wallet, exactly as in PPS, and a matured block is split across the shares that produced it into the same pps_credits table PPS uses. A miner reading the strip on a pplns pool was told the pool owed them nothing, on the one page whose whole purpose is stating what a stratum URL cannot show. Both pplns modes now describe what actually happens -- nothing credited when a share arrives, blocks split across their window once matured 100 deep -- and name the rail the balance is finally paid over, which is the fact a miner most needs because it is also what their username has to be. poolMeta().accrues was PPS-only for the same reason. It means "does a balance build up between payouts", which is true of pps-classic and of both pplns modes and false only of solo, whose coinbase pays the finder directly. It is deliberately not "is there a rate" -- PPS prices a share when it arrives and pplns values it in hindsight out of a block actually found, so only PPS leaves rate_used on the row. rate_source and rate_sats_per_diff stay the PPS-only facts. Not touched here: the audit page's rate framing is still PPS-shaped. It does not misreport a pplns pool -- every share has rate_used = 0, so the verification counts nothing, reports ok and claims full coverage -- but "rate" and per-share re-derivation are the wrong questions to be asking of a mode that prices in hindsight. That is a bigger change than a label. Both new tests fail against the previous template and pass against this one. Dashboard 138 passing, payout 77, C suites unchanged. --- dashboard/lib/stats.js | 14 ++++++++- dashboard/test/pool-identity.test.js | 38 +++++++++++++++++++++++ dashboard/views/partial/pool-identity.ejs | 9 ++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/dashboard/lib/stats.js b/dashboard/lib/stats.js index beb42a6..667073d 100644 --- a/dashboard/lib/stats.js +++ b/dashboard/lib/stats.js @@ -500,7 +500,19 @@ export function poolMeta(handle) { /* An override whose implied fee has drifted from fee_bps is the * failure this table exists to expose. */ fee_drift_bps: Number(r.effective_fee_bps || 0) - Number(r.fee_bps || 0), - accrues: (r.pool_mode || 'solo') === 'pps-classic', + /* Does a balance build up in pps_credits between payouts? + * + * True of PPS and of both PPLNS modes -- they share the table and + * the payout worker that drains it. Only solo accrues nothing, + * because its coinbase pays the finder directly. + * + * It is deliberately not "is there a rate": PPS prices a share the + * moment it arrives, PPLNS values it in hindsight out of a block + * actually found, and only the former leaves rate_used on the row. + * rate_source and rate_sats_per_diff above are the PPS-only facts; + * this one is about whether the pool owes anyone anything. */ + accrues: ['pps-classic', 'pplns-thunder', 'pplns-btc'] + .includes(r.pool_mode || 'solo'), }; } catch { return null; /* pre-pool_meta DB */ diff --git a/dashboard/test/pool-identity.test.js b/dashboard/test/pool-identity.test.js index 45eade7..5012669 100644 --- a/dashboard/test/pool-identity.test.js +++ b/dashboard/test/pool-identity.test.js @@ -164,3 +164,41 @@ test('malformed listener JSON does not take the strip down', async () => { * ports must still say where the money goes. */ assert.ok(html.includes(OPERATOR)); }); + +/* PPLNS is neither of the two modes the strip used to know about, and calling + * it either one misstates where a miner's money is. + * + * The fallback branch was solo's: "Each block's coinbase pays the miner who + * found it, directly. No share credit accrues between blocks." Both halves are + * false under PPLNS -- the coinbase pays the pool wallet, and a matured block + * is split across the shares that produced it into the same pps_credits table + * PPS uses. A miner reading that would conclude the pool owed them nothing. */ +test('a pplns pool is not described as solo', async () => { + for (const mode of ['pplns-thunder', 'pplns-btc']) { + const html = await strip(makeDb({ pool_mode: mode })); + assert.match(html, new RegExp(mode), `${mode} must be named`); + assert.doesNotMatch(html, /No share credit accrues between blocks/, + `${mode} must not carry solo's description`); + assert.doesNotMatch(html, /credited at a fixed rate per unit of difficulty/, + `${mode} must not carry the PPS description either`); + assert.match(html, /split across the shares that produced it/, + `${mode} must say how a block is actually divided`); + } +}); + +/* The rail is the one thing the two pplns modes do not share, and it decides + * what a stratum username is -- so it is the fact a miner most needs. */ +test('the strip names the rail a pplns balance is paid over', async () => { + assert.match(await strip(makeDb({ pool_mode: 'pplns-btc' })), /Bitcoin L1/); + assert.match(await strip(makeDb({ pool_mode: 'pplns-thunder' })), /Thunder/); +}); + +/* accrues means "a balance builds up between payouts", which is true of PPS + * and both pplns modes and false only of solo. Reporting it false for pplns + * would describe a pool that owes its miners nothing. */ +test('accrues is true for every mode that credits pps_credits', () => { + assert.equal(poolMeta(makeDb({ pool_mode: 'pps-classic' })).accrues, true); + assert.equal(poolMeta(makeDb({ pool_mode: 'pplns-thunder' })).accrues, true); + assert.equal(poolMeta(makeDb({ pool_mode: 'pplns-btc' })).accrues, true); + assert.equal(poolMeta(makeDb({ pool_mode: 'solo' })).accrues, false); +}); diff --git a/dashboard/views/partial/pool-identity.ejs b/dashboard/views/partial/pool-identity.ejs index e5bc73e..cc89f5b 100644 --- a/dashboard/views/partial/pool-identity.ejs +++ b/dashboard/views/partial/pool-identity.ejs @@ -38,6 +38,15 @@ <% if (_mode === 'pps-classic') { %> <%= _mode %> + <% } else if (_mode === 'pplns-thunder' || _mode === 'pplns-btc') { %> + <%# Not solo and not PPS, and saying either would be a lie about + where the money is. Blocks pay the pool wallet, as in PPS — + but nothing is credited when a share arrives. A matured + block is split across the shares that produced it, so the + miners carry the variance and the pool never owes more than + it has just been paid. The two modes differ only in the rail + the balance is finally paid over. %> + <%= _mode %> <% } else if (_mode) { %> <%= _mode %> <% } else { %> From 8f952326fc4231914628690927262e119a3a64ba Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 7 Sep 2026 10:23:21 +0200 Subject: [PATCH 07/18] Run the confirmation pass on every new tip, and prove pplns end to end The pplns e2e is the thing this branch was missing: unit tests prove store_pplns_distribute splits a window correctly given a database somebody hand-built to contain a matured block, and nothing proved a running pool ever reaches that state. It does not, and finding out why turned up a bug that has nothing to do with pplns. reconcile_blocks was guarded by if (t->height - 1 != s->last_height) reconcile_blocks(s, t->height - 1); and last_height holds the TEMPLATE height, which is the tip plus one. So the condition asks whether the new tip differs from the previous tip PLUS ONE, which on an ordinary one-block advance is false. The pass ran only when the tip jumped two or more blocks between polls. That is the single most common event on any chain. Blocks sat at 'pending' with checked_via unset, never confirmed, never counted deeper -- and pplns distribution hangs off the end of that same pass, so a pplns pool credited nobody, ever. Measured before the fix: 51 consecutive single-block tip advances, 51 jobs rebuilt, zero reconcile passes. It now keys on new_tip, which is computed a few lines above from both the height and the previous hash. That is the correct notion, and it catches one more case a height comparison of any kind cannot see: a reorg that replaces the tip at the same height. Pre-existing, from f8a11e4; nothing in this branch touched that line. It surfaced here because pplns is the first feature whose visible output depends on the pass running. The test itself covers both rails, because pool_mode decides two things at once and only one of them is the rail: pplns-thunder and pplns-btc pool the reward identically and share every line of the distribution path, differing only in what a stratum username IS. Each authorizes with its own username shape, so a regression in one rail's validation cannot hide behind the other passing. It asserts the maturity gate as an absence before asserting distribution as a presence. A confirmed block only 11 deep must credit nobody -- crediting a coinbase before it is spendable creates a balance the pool cannot fund, which is the reserve requirement pplns exists to remove. Asserting only the end state would pass just as well against a distributor with no maturity check at all. Both rails, on a real enforcer template: window snapshotted at find time (9.313e-10, twice the regtest difficulty), nothing credited at 11 deep, distributed at 111 deep, 4950000000 sats credited of a 5000000000 gross -- reward plus fees, net of the 1% operator fee, to the exact sat -- and the balance unmoved after five further tips, which is the exactly-once latch. The nudge loops mine one block at a time on purpose. That is precisely the case the old condition missed, so the test would have caught this bug by construction rather than by luck. Regression-checked: the pps-classic e2e and the payout e2e both still pass, the latter also exercising the schema.sql change from the previous commit. C suites all green. CI runs it after the existing e2e; the job timeout goes to 35 minutes because this one mines a chain to maturity twice over. --- .github/workflows/integration_tests.yaml | 13 +- .gitignore | 1 + src/main.c | 18 +- tests/test_pplns_regtest.sh | 333 +++++++++++++++++++++++ 4 files changed, 362 insertions(+), 3 deletions(-) create mode 100755 tests/test_pplns_regtest.sh diff --git a/.github/workflows/integration_tests.yaml b/.github/workflows/integration_tests.yaml index a79d539..4512d0c 100644 --- a/.github/workflows/integration_tests.yaml +++ b/.github/workflows/integration_tests.yaml @@ -21,7 +21,7 @@ on: jobs: integration-test: runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 35 # Match check_build.yaml: CI runs only in the LayerTwo-Labs org repo. if: github.repository_owner == 'LayerTwo-Labs' steps: @@ -48,6 +48,14 @@ jobs: - name: Run end-to-end regtest test run: bash tests/test_e2e_regtest.sh + # PPLNS distribution, both rails. Separate from the pps-classic e2e + # above because it is testing a different thing: not the coinbase shape + # but what happens 100 blocks later, when a matured block is split + # across the window that produced it. It mines its own chain to + # maturity, so it is slower and worth failing independently. + - name: Run PPLNS end-to-end regtest test + run: bash tests/test_pplns_regtest.sh + - name: Upload logs if: failure() uses: actions/upload-artifact@v4 @@ -55,9 +63,12 @@ jobs: name: e2e-logs-${{ github.run_id }} path: | .regtest-e2e/logs/ + .regtest-pplns/logs/ /tmp/simplepool-e2e.log /tmp/simplepool-e2e.conf /tmp/simplepool-int.log + /tmp/simplepool-pplns-*.log + /tmp/simplepool-pplns-*.conf retention-days: 14 if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index b06c4b9..554de85 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /.regtest/ /.regtest-e2e/ /.regtest-payout/ +/.regtest-pplns/ /proxy.conf /tests/integration.proxy.conf # The installer writes proxy.conf.bak. beside proxy.conf on every diff --git a/src/main.c b/src/main.c index 0481702..e32f944 100644 --- a/src/main.c +++ b/src/main.c @@ -823,8 +823,22 @@ static void *tip_watcher(void *arg) { /* A new tip is exactly when a candidate's fate can have changed: * either it is the one that extended the chain, or something else - * was. */ - if (t->height - 1 != s->last_height) reconcile_blocks(s, t->height - 1); + * was. + * + * new_tip, not a comparison of our own against last_height. + * last_height holds the TEMPLATE height, which is the tip plus one, + * so `t->height - 1 != s->last_height` asks whether the new tip + * differs from the previous tip PLUS ONE. On an ordinary one-block + * advance that is false -- the single most common event on any + * chain, and the one case this has to catch. It fired only when the + * tip jumped two or more blocks between polls, which is why blocks + * sat at 'pending' on a quiet chain and PPLNS, whose distribution + * hangs off this pass, credited nobody at all. + * + * new_tip is computed above from both the height and the previous + * hash, so it also catches a reorg that replaces the tip at the same + * height -- which a height comparison of any kind cannot see. */ + if (new_tip) reconcile_blocks(s, t->height - 1); if (need_rebuild) { char berr[256] = {0}; diff --git a/tests/test_pplns_regtest.sh b/tests/test_pplns_regtest.sh new file mode 100755 index 0000000..65b2619 --- /dev/null +++ b/tests/test_pplns_regtest.sh @@ -0,0 +1,333 @@ +#!/usr/bin/env bash +# End-to-end test of the PPLNS distribution path, for BOTH rails. +# +# bitcoind-patched <-ZMQ/RPC- bip300301_enforcer (walletless) +# ^ | GBT +# | submitblock v +# +----------------------- simplepool (pplns-thunder | pplns-btc) +# ^ stratum +# | +# cpuminer.js +# +# What this covers that the unit tests cannot. +# +# tests/test_store.c proves store_pplns_distribute splits a window +# correctly, given a database somebody hand-built to have a matured block +# in it. Nothing proved that a real pool ever reaches that state: that a +# block found through stratum gets a window snapshotted onto its row, that +# the confirmation pass keeps counting its depth until it reaches 100, and +# that the distributor then actually runs and credits somebody. Every one +# of those is a different file, and the seam between them is where a mode +# that passes its unit tests still pays nobody. +# +# Both rails run, because pool_mode decides two things at once and only one +# of them is the rail. pplns-thunder and pplns-btc pool the reward +# identically and share every line of the distribution path; what differs +# is what a stratum username IS -- a Thunder address on one, a Bitcoin +# address on the other. So each rail authorizes with its own username shape +# against its own pool, and both must reach the same credited ledger. A +# regression that broke username validation for one rail would otherwise +# hide behind the other passing. +# +# The maturity gate is asserted as an absence before it is asserted as a +# presence. A confirmed-but-shallow block must credit NOBODY: crediting a +# coinbase before it is spendable creates a balance the pool cannot fund, +# which is the reserve requirement PPLNS exists to remove. Asserting only +# the end state would pass just as well against a distributor with no +# maturity check at all, which is the exact bug worth catching. +# +# Deterministic by construction: a fresh chain and a fresh database every +# run, its own data dir, and per-run ports, so it coexists with a dev stack +# in .regtest/ and with the other two e2e suites. +# +# Env: +# REGTEST_DIR data dir, WIPED each run (default: /.regtest-pplns) +# REGTEST_BIN_DIR binary cache, kept across runs (default: /.regtest/bin) +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +export REGTEST_DIR="${REGTEST_DIR:-$ROOT/.regtest-pplns}" +export REGTEST_BIN_DIR="${REGTEST_BIN_DIR:-$ROOT/.regtest/bin}" +export REGTEST_SKIP_THUNDER=1 +export REGTEST_WALLETLESS=1 + +BIN="$REGTEST_BIN_DIR" +POOL_BIN="$ROOT/build/simplepool" +RPC="$ROOT/scripts/enforcer-rpc.sh" + +# Maturity, and the depth the proxy itself uses (PPLNS_MATURITY_CONFS and +# BLOCK_FINAL_DEPTH in src/main.c). Kept as a name so the two places that +# mine toward it cannot drift apart. +MATURITY=100 +# Deliberately short of maturity: deep enough that the block is certainly +# confirmed, shallow enough that crediting it would be a bug. +SHALLOW=10 + +OPERATOR_ADDR="bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080" +# Distinct from OPERATOR_ADDR so the fee output and the pool output are +# distinguishable on-chain. +POOL_BTC_ADDR="bcrt1qqypqxpq9qcrsszg2pvxq6rs0zqg3yyc5phstwt" +# What a miner types as its username, per rail. The Bitcoin one is a +# different address again from the two above: it is a payout destination, +# not a coinbase output, and conflating them is how a test passes while +# paying the wrong party. +BTC_USER="bcrt1qzyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3lgth6c" +THUNDER_USER="11111111111111111111" +FEE_BPS=100 + +POOL_PID="" + +cli() { "$BIN/bitcoin-cli" -datadir="$REGTEST_DIR/data/bitcoind" -regtest \ + -rpcuser=user -rpcpassword=password "$@"; } + +# Mine through the enforcer rather than bitcoind directly, so the enforcer's +# own view of the chain advances with it -- it is the thing serving GBT, and +# a tip it has not seen produces no template and no confirmation pass. +mine() { RPC_TIMEOUT=180 "$RPC" cusf.mainchain.v1.MiningService/GenerateToAddress \ + '{"blocks": '"$1"', "address": "'"$OPERATOR_ADDR"'"}' >/dev/null; } + +q() { sqlite3 "$POOL_DB" "$1"; } + +stage() { echo; echo "=== pplns-e2e: $1"; } + +dump_logs() { + echo "!!! pplns e2e FAILED — recent logs:" >&2 + for f in "$REGTEST_DIR"/logs/*.log "${POOL_LOG:-}"; do + [ -f "$f" ] || continue + echo "--- tail $f" >&2 + tail -40 "$f" >&2 + done +} + +cleanup() { + [ -n "$POOL_PID" ] && kill "$POOL_PID" 2>/dev/null || true + "$ROOT/scripts/regtest/stop.sh" || true + rm -rf "$LOCK" +} + +LOCK="$REGTEST_DIR.lock" +if ! mkdir "$LOCK" 2>/dev/null; then + echo "FAIL: $LOCK exists — another run of this suite is active." >&2 + echo "If it crashed and left the lock behind, clear it with:" >&2 + echo " REGTEST_DIR=$REGTEST_DIR scripts/regtest/stop.sh && rm -rf $LOCK" >&2 + exit 1 +fi +trap 'code=$?; [ "$code" -ne 0 ] && dump_logs; cleanup; exit $code' EXIT +trap 'exit 130' INT TERM + +for dep in sqlite3 jq node nc curl; do + command -v "$dep" >/dev/null 2>&1 || { echo "$dep not installed" >&2; exit 1; } +done + +PICKED="" +pick_port() { + local p + while :; do + p=$(( (RANDOM % 20000) + 20001 )) + [[ " $PICKED " == *" $p "* ]] && continue + nc -z 127.0.0.1 "$p" 2>/dev/null && continue + PICKED="$PICKED $p" + printf -v "$1" '%s' "$p" + return + done +} + +stage "allocate stack ports" +pick_port REGTEST_BITCOIND_RPC_PORT +pick_port REGTEST_BITCOIND_ZMQ_PORT +pick_port REGTEST_ENFORCER_RPC_PORT +pick_port REGTEST_ENFORCER_GRPC_PORT +pick_port POOL_PORT +export REGTEST_BITCOIND_RPC_PORT REGTEST_BITCOIND_ZMQ_PORT \ + REGTEST_ENFORCER_RPC_PORT REGTEST_ENFORCER_GRPC_PORT +export ENFORCER_URL="http://127.0.0.1:$REGTEST_ENFORCER_GRPC_PORT" +echo " bitcoind=$REGTEST_BITCOIND_RPC_PORT enforcer=$REGTEST_ENFORCER_RPC_PORT/$REGTEST_ENFORCER_GRPC_PORT pool=$POOL_PORT" + +stage "wipe data dir (fresh chain every run)" +rm -rf "$REGTEST_DIR/data" "$REGTEST_DIR/logs" "$REGTEST_DIR/run" + +stage "build simplepool" +make -C "$ROOT" -j >/dev/null + +stage "download prebuilt binaries" +"$ROOT/scripts/regtest/setup.sh" + +stage "start bitcoind-patched + walletless enforcer" +"$ROOT/scripts/regtest/start.sh" + +stage "activate sidechain #9 via enforcer-template mining" +# PPLNS emits a classic coinbase, same as pps-classic, so the sidechain is +# not what is under test. It is activated anyway so the enforcer's template +# still carries the BIP301 commitment outputs the coinbase builder has to +# preserve -- mining against a template without them would exercise an +# easier case than production ever runs. +"$ROOT/scripts/regtest/activate-thunder.sh" + +# --------------------------------------------------------------------------- +# One rail. +# --------------------------------------------------------------------------- +run_rail() { + local mode="$1" user="$2" + POOL_CONF="/tmp/simplepool-pplns-$mode.conf" + POOL_LOG="/tmp/simplepool-pplns-$mode.log" + POOL_DB="/tmp/simplepool-pplns-$mode.db" + + stage "[$mode] start simplepool against enforcer GBT" + rm -f "$POOL_DB" "$POOL_DB-wal" "$POOL_DB-shm" + cat > "$POOL_CONF" < "$POOL_LOG" 2>&1 & + POOL_PID=$! + for _ in $(seq 1 20); do nc -z 127.0.0.1 "$POOL_PORT" 2>/dev/null && break; sleep 1; done + kill -0 "$POOL_PID" 2>/dev/null || { echo "simplepool died on startup" >&2; exit 1; } + + stage "[$mode] mine one block through stratum as $user" + local before after + before=$(cli getblockcount) + node "$ROOT/scripts/regtest/cpuminer.js" --port "$POOL_PORT" --user "$user" --timeout 180 + after=$(cli getblockcount) + echo " height: $before -> $after" + [ "$after" -gt "$before" ] || { + echo "FAIL: [$mode] block submitted but the chain did not advance" >&2; exit 1; } + + # The username rule is half of what pool_mode decides, so prove the pool + # actually took this rail's shape rather than accepting anything. + local nworkers + nworkers=$(q "SELECT COUNT(*) FROM workers WHERE name = '$user'") + [ "$nworkers" = "1" ] || { + echo "FAIL: [$mode] '$user' was not authorized as a worker" >&2; exit 1; } + + stage "[$mode] the block carries a snapshotted window" + local hash window + hash=$(q "SELECT hash FROM blocks_found ORDER BY id DESC LIMIT 1") + [ -n "$hash" ] || { echo "FAIL: [$mode] no block row was written" >&2; exit 1; } + window=$(q "SELECT pplns_window_diff FROM blocks_found WHERE hash='$hash'") + echo " block $hash window=$window" + # Snapshotted at find time; a zero window is skipped by the distributor, + # so this is the difference between paying out and silently never paying. + awk -v w="$window" 'BEGIN { exit !(w > 0) }' || { + echo "FAIL: [$mode] block was recorded with no PPLNS window" >&2; exit 1; } + + stage "[$mode] a confirmed but immature block credits nobody" + mine "$SHALLOW" + # The proxy reconciles only when the tip HEIGHT changes, and it reaches + # the next tip through a long poll that can sit for 30s. So drive it: + # mine one more block per attempt rather than waiting on a cadence this + # test does not control. Each nudge is one confirmation deeper, and the + # budget here stays far short of $MATURITY -- the assertion below checks + # that rather than assuming it. + local confs credited + for _ in $(seq 1 40); do + confs=$(q "SELECT COALESCE(confirmations,0) FROM blocks_found WHERE hash='$hash'") + [ "${confs:-0}" -ge 1 ] && break + mine 1 + sleep 3 + done + echo " confirmations=$confs (maturity is $MATURITY)" + [ "${confs:-0}" -ge 1 ] || { + echo "FAIL: [$mode] block never reached even one confirmation" >&2; exit 1; } + # The whole point of this stage: it must still be short of maturity, or + # it proves nothing about the gate. + [ "${confs:-0}" -lt "$MATURITY" ] || { + echo "FAIL: [$mode] block reached $confs confirmations before the" >&2 + echo " immaturity check could run — the nudge budget above is" >&2 + echo " too large relative to maturity ($MATURITY)" >&2 + exit 1; } + credited=$(q "SELECT COALESCE(SUM(accrued_sats),0) FROM pps_credits") + [ "$credited" = "0" ] || { + echo "FAIL: [$mode] $credited sats credited from a block only $confs deep —" >&2 + echo " a coinbase is unspendable until $MATURITY, so this is a balance" >&2 + echo " the pool cannot fund" >&2 + exit 1; } + echo " credited nothing, as required" + + stage "[$mode] mature the block and distribute" + mine "$MATURITY" + local dist + for _ in $(seq 1 40); do + dist=$(q "SELECT COALESCE(pplns_distributed,0) FROM blocks_found WHERE hash='$hash'") + [ "${dist:-0}" = "1" ] && break + mine 1 # same nudge: a tip change is what runs the pass + sleep 3 + done + confs=$(q "SELECT COALESCE(confirmations,0) FROM blocks_found WHERE hash='$hash'") + echo " confirmations=$confs distributed=$dist" + [ "${dist:-0}" = "1" ] || { + echo "FAIL: [$mode] block is $confs deep and still undistributed" >&2 + echo " (maturity $MATURITY) — the distributor never ran or never" >&2 + echo " considered it eligible" >&2 + exit 1; } + + stage "[$mode] the credited ledger matches the block, net of the fee" + local reward fee gross payable total + reward=$(q "SELECT COALESCE(reward_sats,0) FROM blocks_found WHERE hash='$hash'") + fee=$(q "SELECT COALESCE(fee_sats,0) FROM blocks_found WHERE hash='$hash'") + gross=$(( reward + fee )) + # Same truncating arithmetic as store_pplns_distribute. + payable=$(( gross - (gross * FEE_BPS) / 10000 )) + total=$(q "SELECT COALESCE(SUM(accrued_sats),0) FROM pps_credits") + echo " reward=$reward fee=$fee gross=$gross payable=$payable credited=$total" + # Fees are included deliberately: PPLNS shares what the block actually + # earned, not a subsidy-only estimate. + [ "$total" = "$payable" ] || { + echo "FAIL: [$mode] credited $total sats, expected $payable" >&2; exit 1; } + # One miner, so the whole payable amount is its own -- and it must be + # THIS rail's username that holds it. + local mine_sats + mine_sats=$(q "SELECT COALESCE(SUM(c.accrued_sats),0) FROM pps_credits c + JOIN workers w ON w.id = c.worker_id WHERE w.name = '$user'") + [ "$mine_sats" = "$payable" ] || { + echo "FAIL: [$mode] '$user' holds $mine_sats of $payable" >&2; exit 1; } + + stage "[$mode] distribution is exactly once" + # Crediting is additive and there is no negative share, so a second pass + # over the same block doubles every balance and leaves no trace in the + # amounts themselves. Give the confirmation pass several more tips to + # run over an already-distributed block. + mine 5 + sleep 8 + local again + again=$(q "SELECT COALESCE(SUM(accrued_sats),0) FROM pps_credits") + [ "$again" = "$payable" ] || { + echo "FAIL: [$mode] balance moved from $payable to $again after further" >&2 + echo " passes — the block was distributed more than once" >&2 + exit 1; } + echo " balance still $again after 5 more tips" + + kill "$POOL_PID" 2>/dev/null || true + wait "$POOL_PID" 2>/dev/null || true + POOL_PID="" +} + +run_rail pplns-thunder "$THUNDER_USER" +run_rail pplns-btc "$BTC_USER" + +echo +echo "pplns e2e: PASS (both rails distributed a matured block, exactly once)" From 0a70554415d8dc6a6c7badcad54f173449fe16df Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 7 Sep 2026 11:54:05 +0200 Subject: [PATCH 08/18] Distribute on the getblockhash path too, not just the templates one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second half of the same bug. reconcile_blocks settles block statuses by one of two mechanisms -- the node's own getblockhash where the backend serves it, and the observed chain of template tips where it does not -- and PPLNS distribution ran at the end, after both. Except it did not: if (atomic_load(&s->gbh_state) > 0) return; /* before everything below */ gbh_state latches to 1 on the first successful getblockhash and stays there for the life of the process, so on any backend that serves the call, every subsequent pass returned before reaching the distributor. A pool on an ordinary bitcoind confirmed its blocks, counted them past 100 deep, and credited nobody, ever. Nothing about that looks wrong from the outside. The rows carry a window, a status and the depth; only pps_credits stays empty. It is the same failure as the previous commit's, reached down the other branch, and it is worse because the previous one at least stalled confirmations too. The early return existed for a real reason: getblockhash is the node's own answer, so where there is one it wins outright and the weaker templates fallback must not run and overwrite its verdict and its checked_via. That reason applies to the fallback, not to the distribution. So it is now a flag that skips the fallback, and the distribution sits on the function's single exit path, reached however the statuses were settled. The transient getblockhash failure does the same: it still leaves the rows alone, but no longer stops paying out rows an earlier pass already settled. The e2e could not have caught this. It runs against the enforcer, which serves no getblockhash, so the branch was never taken -- which is exactly how the bug survived the last commit. It now runs a third scenario with the proxy pointed at plain bitcoind, where getblockhash is served and the preferred path is the one under test. Verified by mutation: against the previous code that scenario fails with "block is 100 deep and still undistributed", confirmations=100 distributed=0; with the fix it distributes 2475000000 sats of a 2500000000 gross, exact to the sat. Both enforcer scenarios still pass, as do the pps-classic and payout e2e suites and the C suites. ⚠️ left in the source above the distribution: it must stay on the single exit path, and adding a return above it silently stops a pplns pool paying. --- src/main.c | 45 ++++++++++++++++----- tests/test_pplns_regtest.sh | 79 +++++++++++++++++++++++++------------ 2 files changed, 90 insertions(+), 34 deletions(-) diff --git a/src/main.c b/src/main.c index e32f944..f518766 100644 --- a/src/main.c +++ b/src/main.c @@ -669,6 +669,11 @@ static void on_block_found_cb(void *ctx, const char *worker_name, static void reconcile_blocks(server_ctx_t *s, int tip_height) { if (!s || !s->store || tip_height <= 0) return; + /* Whether this pass has already settled candidate statuses, so the + * templates fallback below must not run and overwrite them. NOT a reason + * to skip the distribution at the end -- see there. */ + int settled = 0; + if (atomic_load(&s->gbh_state) >= 0) { store_block_candidate_t cands[RECONCILE_MAX_PER_TICK]; int n = store_list_unresolved_blocks(s->store, tip_height, @@ -688,9 +693,15 @@ static void reconcile_blocks(server_ctx_t *s, int tip_height) { } if (rc != 0) { /* Transient. Leave the rows alone and retry on the next tip - * rather than recording a verdict we did not get. */ + * rather than recording a verdict we did not get. Statuses + * are untouched, so the templates pass must not run either -- + * but this is not a reason to stop paying: distribution reads + * only rows settled by an earlier pass, and a backend that + * kept failing this one call would otherwise silently stop + * crediting anyone. */ LOG_WARN("getblockhash(%d) failed: %s", cands[i].height, gerr); - return; + settled = 1; + break; } atomic_store(&s->gbh_state, 1); int match = strcasecmp(have, cands[i].hash) == 0; @@ -704,14 +715,21 @@ static void reconcile_blocks(server_ctx_t *s, int tip_height) { "marked orphaned", cands[i].hash, cands[i].height); } } - if (atomic_load(&s->gbh_state) > 0) return; + /* getblockhash is the node's own answer, so where the backend has + * one it wins outright and the templates fallback below is skipped + * -- running both would have the weaker check overwrite the stronger + * one's verdict and its checked_via. */ + if (atomic_load(&s->gbh_state) > 0) settled = 1; } - int confirmed = 0, orphaned = 0, pending = 0; - if (store_reconcile_blocks_from_templates(s->store, tip_height, &confirmed, - &orphaned, &pending) == 0) { - LOG_DEBUG("block reconcile: confirmed=%d orphaned=%d pending=%d", - confirmed, orphaned, pending); + if (!settled) { + int confirmed = 0, orphaned = 0, pending = 0; + if (store_reconcile_blocks_from_templates(s->store, tip_height, + &confirmed, &orphaned, + &pending) == 0) { + LOG_DEBUG("block reconcile: confirmed=%d orphaned=%d pending=%d", + confirmed, orphaned, pending); + } } /* PPLNS pays out here rather than at block-find time, because this is the @@ -721,7 +739,16 @@ static void reconcile_blocks(server_ctx_t *s, int tip_height) { * block that later turns out not to be ours cannot be taken back. Running * it off the confirmation pass, gated on maturity, means it only ever sees * blocks that are 100 deep — by which point "still in the chain" has - * stopped being a question. */ + * stopped being a question. + * + * ⚠️ This must stay on the function's single exit path, reached however + * the statuses above were settled. It used to sit behind an early return + * taken whenever the backend served getblockhash — and since that state + * latches on for the life of the process, a pool on a getblockhash-capable + * node confirmed its blocks, counted them past 100 deep, and then never + * distributed one. Nothing looked wrong: the rows carry a window, a + * status and the depth, and only pps_credits stays empty. Do not add a + * `return` above this without moving it. */ if (s->cfg && (strcmp(s->cfg->pool_mode, "pplns-thunder") == 0 || strcmp(s->cfg->pool_mode, "pplns-btc") == 0)) { int blocks = 0, workers = 0; diff --git a/tests/test_pplns_regtest.sh b/tests/test_pplns_regtest.sh index 65b2619..bc28bde 100755 --- a/tests/test_pplns_regtest.sh +++ b/tests/test_pplns_regtest.sh @@ -167,19 +167,44 @@ stage "activate sidechain #9 via enforcer-template mining" # --------------------------------------------------------------------------- # One rail. # --------------------------------------------------------------------------- +# run_rail [backend] +# +# backend selects which node serves getblocktemplate, and it is not a detail: +# it decides which of the two confirmation mechanisms the proxy uses. +# +# enforcer (default) serves no getblockhash, so blocks are confirmed from +# the observed chain of template tips +# bitcoind serves getblockhash, which the proxy prefers, and +# which latches on for the life of the process +# +# Both must end up distributing. The getblockhash path is the one that used to +# return before ever reaching the distributor, so a pool on an ordinary +# bitcoind confirmed its blocks, counted them past maturity, and credited +# nobody -- with nothing in the rows to show for it. The enforcer serves no +# getblockhash, so no amount of testing against it can see that. run_rail() { - local mode="$1" user="$2" - POOL_CONF="/tmp/simplepool-pplns-$mode.conf" - POOL_LOG="/tmp/simplepool-pplns-$mode.log" - POOL_DB="/tmp/simplepool-pplns-$mode.db" - - stage "[$mode] start simplepool against enforcer GBT" + local mode="$1" user="$2" backend="${3:-enforcer}" + local tag="$mode-$backend" + POOL_CONF="/tmp/simplepool-pplns-$tag.conf" + POOL_LOG="/tmp/simplepool-pplns-$tag.log" + POOL_DB="/tmp/simplepool-pplns-$tag.db" + + local rpc_lines + if [ "$backend" = "bitcoind" ]; then + rpc_lines="bitcoind_url = http://127.0.0.1:${REGTEST_BITCOIND_RPC_PORT} +bitcoind_user = user +bitcoind_pass = password" + else + rpc_lines="bitcoind_url = http://127.0.0.1:${REGTEST_ENFORCER_RPC_PORT}" + fi + + stage "[$tag] start simplepool against $backend GBT" rm -f "$POOL_DB" "$POOL_DB-wal" "$POOL_DB-shm" cat > "$POOL_CONF" </dev/null && break; sleep 1; done kill -0 "$POOL_PID" 2>/dev/null || { echo "simplepool died on startup" >&2; exit 1; } - stage "[$mode] mine one block through stratum as $user" + stage "[$tag] mine one block through stratum as $user" local before after before=$(cli getblockcount) node "$ROOT/scripts/regtest/cpuminer.js" --port "$POOL_PORT" --user "$user" --timeout 180 after=$(cli getblockcount) echo " height: $before -> $after" [ "$after" -gt "$before" ] || { - echo "FAIL: [$mode] block submitted but the chain did not advance" >&2; exit 1; } + echo "FAIL: [$tag] block submitted but the chain did not advance" >&2; exit 1; } # The username rule is half of what pool_mode decides, so prove the pool # actually took this rail's shape rather than accepting anything. local nworkers nworkers=$(q "SELECT COUNT(*) FROM workers WHERE name = '$user'") [ "$nworkers" = "1" ] || { - echo "FAIL: [$mode] '$user' was not authorized as a worker" >&2; exit 1; } + echo "FAIL: [$tag] '$user' was not authorized as a worker" >&2; exit 1; } - stage "[$mode] the block carries a snapshotted window" + stage "[$tag] the block carries a snapshotted window" local hash window hash=$(q "SELECT hash FROM blocks_found ORDER BY id DESC LIMIT 1") - [ -n "$hash" ] || { echo "FAIL: [$mode] no block row was written" >&2; exit 1; } + [ -n "$hash" ] || { echo "FAIL: [$tag] no block row was written" >&2; exit 1; } window=$(q "SELECT pplns_window_diff FROM blocks_found WHERE hash='$hash'") echo " block $hash window=$window" # Snapshotted at find time; a zero window is skipped by the distributor, # so this is the difference between paying out and silently never paying. awk -v w="$window" 'BEGIN { exit !(w > 0) }' || { - echo "FAIL: [$mode] block was recorded with no PPLNS window" >&2; exit 1; } + echo "FAIL: [$tag] block was recorded with no PPLNS window" >&2; exit 1; } - stage "[$mode] a confirmed but immature block credits nobody" + stage "[$tag] a confirmed but immature block credits nobody" mine "$SHALLOW" # The proxy reconciles only when the tip HEIGHT changes, and it reaches # the next tip through a long poll that can sit for 30s. So drive it: @@ -252,23 +277,23 @@ EOF done echo " confirmations=$confs (maturity is $MATURITY)" [ "${confs:-0}" -ge 1 ] || { - echo "FAIL: [$mode] block never reached even one confirmation" >&2; exit 1; } + echo "FAIL: [$tag] block never reached even one confirmation" >&2; exit 1; } # The whole point of this stage: it must still be short of maturity, or # it proves nothing about the gate. [ "${confs:-0}" -lt "$MATURITY" ] || { - echo "FAIL: [$mode] block reached $confs confirmations before the" >&2 + echo "FAIL: [$tag] block reached $confs confirmations before the" >&2 echo " immaturity check could run — the nudge budget above is" >&2 echo " too large relative to maturity ($MATURITY)" >&2 exit 1; } credited=$(q "SELECT COALESCE(SUM(accrued_sats),0) FROM pps_credits") [ "$credited" = "0" ] || { - echo "FAIL: [$mode] $credited sats credited from a block only $confs deep —" >&2 + echo "FAIL: [$tag] $credited sats credited from a block only $confs deep —" >&2 echo " a coinbase is unspendable until $MATURITY, so this is a balance" >&2 echo " the pool cannot fund" >&2 exit 1; } echo " credited nothing, as required" - stage "[$mode] mature the block and distribute" + stage "[$tag] mature the block and distribute" mine "$MATURITY" local dist for _ in $(seq 1 40); do @@ -280,12 +305,12 @@ EOF confs=$(q "SELECT COALESCE(confirmations,0) FROM blocks_found WHERE hash='$hash'") echo " confirmations=$confs distributed=$dist" [ "${dist:-0}" = "1" ] || { - echo "FAIL: [$mode] block is $confs deep and still undistributed" >&2 + echo "FAIL: [$tag] block is $confs deep and still undistributed" >&2 echo " (maturity $MATURITY) — the distributor never ran or never" >&2 echo " considered it eligible" >&2 exit 1; } - stage "[$mode] the credited ledger matches the block, net of the fee" + stage "[$tag] the credited ledger matches the block, net of the fee" local reward fee gross payable total reward=$(q "SELECT COALESCE(reward_sats,0) FROM blocks_found WHERE hash='$hash'") fee=$(q "SELECT COALESCE(fee_sats,0) FROM blocks_found WHERE hash='$hash'") @@ -297,16 +322,16 @@ EOF # Fees are included deliberately: PPLNS shares what the block actually # earned, not a subsidy-only estimate. [ "$total" = "$payable" ] || { - echo "FAIL: [$mode] credited $total sats, expected $payable" >&2; exit 1; } + echo "FAIL: [$tag] credited $total sats, expected $payable" >&2; exit 1; } # One miner, so the whole payable amount is its own -- and it must be # THIS rail's username that holds it. local mine_sats mine_sats=$(q "SELECT COALESCE(SUM(c.accrued_sats),0) FROM pps_credits c JOIN workers w ON w.id = c.worker_id WHERE w.name = '$user'") [ "$mine_sats" = "$payable" ] || { - echo "FAIL: [$mode] '$user' holds $mine_sats of $payable" >&2; exit 1; } + echo "FAIL: [$tag] '$user' holds $mine_sats of $payable" >&2; exit 1; } - stage "[$mode] distribution is exactly once" + stage "[$tag] distribution is exactly once" # Crediting is additive and there is no negative share, so a second pass # over the same block doubles every balance and leaves no trace in the # amounts themselves. Give the confirmation pass several more tips to @@ -316,7 +341,7 @@ EOF local again again=$(q "SELECT COALESCE(SUM(accrued_sats),0) FROM pps_credits") [ "$again" = "$payable" ] || { - echo "FAIL: [$mode] balance moved from $payable to $again after further" >&2 + echo "FAIL: [$tag] balance moved from $payable to $again after further" >&2 echo " passes — the block was distributed more than once" >&2 exit 1; } echo " balance still $again after 5 more tips" @@ -328,6 +353,10 @@ EOF run_rail pplns-thunder "$THUNDER_USER" run_rail pplns-btc "$BTC_USER" +# The same distribution, reached down the other confirmation path. See the +# comment on run_rail: this is the combination that silently paid nobody. +run_rail pplns-thunder "$THUNDER_USER" bitcoind echo -echo "pplns e2e: PASS (both rails distributed a matured block, exactly once)" +echo "pplns e2e: PASS (both rails, and both confirmation paths, distributed a" +echo " matured block exactly once)" From 336a2fd82290ed4c60bc1b52f2421baed583d95a Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 7 Sep 2026 12:05:41 +0200 Subject: [PATCH 09/18] Make the pplns-btc payout rail actually pay, and prove it on L1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rail had unit tests against a stubbed client and nothing else. Pointing it at a real bip300301_enforcer wallet for the first time found three bugs, each of which on its own made pplns-btc unable to pay anyone. 1. run-once.mjs ignored PAYOUT_RAIL. index.js selects the client from cfg.rail; the one-shot entrypoint constructed a ThunderClient unconditionally. On an L1 pool that is a Thunder client with cfg.rpcUrl === null, so the tick found nobody to pay and exited 0 -- a clean run, no payments, no error. It now makes the same choice index.js does. 2. balance() returned the wrong shape, so every tick refused to pay. payout.js reads `BigInt(bal.available_sats ?? bal.total_sats ?? 0)` -- ThunderClient's shape, and the reason the payout loop can drive either rail without branching. EnforcerWalletClient returned a bare BigInt, on which both fields are undefined, so the reserve gate saw zero and stopped every tick with "reserve short — available=0" against a wallet holding 250 BTC. The unit test asserted `await c.balance() === 500000n`, which passes against exactly that bug; it now asserts the way the caller reads it. While here: confirmedSats arrives as a decimal string and went through Number(), which rounds above 2^53 -- about 90,000 BTC. Parsed as a BigInt. 3. Settlement credited miners on broadcast, not on confirmation. Two independent causes, both of which had to be fixed: getTransaction keyed on `confirmations`/`confirmationHeight`. The enforcer reports a confirmationInfo submessage instead -- and emits it for mempool transactions too, carrying only a timestamp ("when we saw it"). A mined one additionally carries height and blockHash. So the field read never matched anything, and once it was made to match, presence alone reported every broadcast as confirmed. It now keys on height/blockHash. walletUtxos counted unconfirmed outputs. payout.js treats a wallet output from the batch's txid as proof the batch settled, and the enforcer applies a transaction to its wallet the moment it broadcasts -- so the change output of an unmined payout appeared immediately. Confirmed outputs only; unconfirmedLastSeen is present exactly while unmined. Either one alone moved paid_sats and wrote the ledger row while the transaction was still in the mempool. That is what "paid means mined, not sent" forbids: crediting is additive and there is no negative credit, so a dropped transaction leaves the debt marked settled and the miner never paid. tests/test_pplns_btc_payout_regtest.sh walks the sequence against a real wallet-enabled enforcer, no Thunder in the stack at all: a tick broadcasts and credits nobody; the next tick blocks on the unconfirmed txid rather than re-broadcasting into a double spend; one L1 block later a tick settles, paid_sats moves exactly once and the in-flight row clears; a further tick pays nothing more. The assertion that matters is the last one, and it reads neither the payout worker's database nor the wallet that sent the money: 250000 sats unspent at the miner's own address, straight out of the chain's UTXO set. Every other check above would still pass if the ledger were being written with no payment behind it. Three unit tests pin the enforcer's real response shapes so the next refactor cannot quietly reintroduce any of this. payout 83, dashboard 138, and the Thunder payout e2e still passes. --- .github/workflows/integration_tests.yaml | 11 +- .gitignore | 1 + payout/lib/enforcer-wallet.js | 58 ++++- payout/run-once.mjs | 26 ++- payout/test/enforcer-wallet.test.js | 89 +++++++- tests/test_pplns_btc_payout_regtest.sh | 260 +++++++++++++++++++++++ 6 files changed, 433 insertions(+), 12 deletions(-) create mode 100755 tests/test_pplns_btc_payout_regtest.sh diff --git a/.github/workflows/integration_tests.yaml b/.github/workflows/integration_tests.yaml index 4512d0c..2e6a9d2 100644 --- a/.github/workflows/integration_tests.yaml +++ b/.github/workflows/integration_tests.yaml @@ -132,11 +132,20 @@ jobs: - name: Run payout regtest test run: bash tests/test_payout_regtest.sh + # The other rail. pplns-btc pays on L1 through the enforcer's own + # wallet and never touches a sidechain, so it shares none of the + # Thunder path above beyond the ledger bookkeeping -- and until this + # existed it had no coverage outside unit tests against a stub. + - name: Run pplns-btc L1 payout regtest test + run: bash tests/test_pplns_btc_payout_regtest.sh + - name: Upload logs if: failure() uses: actions/upload-artifact@v4 with: name: payout-logs-${{ github.run_id }} - path: .regtest-payout/logs/ + path: | + .regtest-payout/logs/ + .regtest-btcpay/logs/ retention-days: 14 if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 554de85..957b9fe 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /.regtest-e2e/ /.regtest-payout/ /.regtest-pplns/ +/.regtest-btcpay/ /proxy.conf /tests/integration.proxy.conf # The installer writes proxy.conf.bak. beside proxy.conf on every diff --git a/payout/lib/enforcer-wallet.js b/payout/lib/enforcer-wallet.js index 901a0a8..fcf687a 100644 --- a/payout/lib/enforcer-wallet.js +++ b/payout/lib/enforcer-wallet.js @@ -63,7 +63,20 @@ export class EnforcerWalletClient { async balance() { const j = await this._call('GetBalance', {}); const sats = j.confirmedSats ?? j.confirmed_sats ?? j.confirmed ?? 0; - return BigInt(Math.floor(Number(sats))); + /* The enforcer sends this as a decimal STRING, so parse it as one + * rather than through Number(), which silently loses precision past + * 2^53 -- reachable by a pool wallet holding more than ~90,000 BTC. */ + const confirmed = typeof sats === 'string' && /^[0-9]+$/.test(sats) + ? BigInt(sats) + : BigInt(Math.floor(Number(sats) || 0)); + /* The shape payout.js reads, which is ThunderClient's: it does + * `BigInt(bal.available_sats ?? bal.total_sats ?? 0)`. Returning the + * bare BigInt instead left both fields undefined, so every tick saw a + * balance of zero and refused to pay with "reserve short" -- on a + * wallet with any amount of money in it. Mirroring the interface is + * the whole reason the payout loop can drive either rail, and this + * was the one place it did not. */ + return { available_sats: confirmed, total_sats: confirmed }; } /* One transaction for the whole batch. Name and shape match @@ -126,8 +139,27 @@ export class EnforcerWalletClient { const rows = j.transactions || j.txs || []; const hit = rows.find(t => (t.txid?.hex ?? t.txid) === txid); if (!hit) return { confirmed: false, known: false, error: null }; - const confs = Number(hit.confirmations ?? hit.confirmationHeight ?? 0); - return { confirmed: confs > 0, known: true, error: null }; + /* The enforcer reports confirmation as a confirmationInfo submessage + * ({height, blockHash, timestamp}) that is simply absent while the + * transaction is in the mempool -- not as a `confirmations` count. + * Reading the count meant this never returned confirmed:true for any + * transaction, ever, which handed every settlement decision to the + * wallet-output cross-check in payout.js. The scalar spellings are + * kept as a fallback in case the RPC grows one. */ + const info = hit.confirmationInfo ?? hit.confirmation_info ?? null; + /* NOT the presence of confirmationInfo: the enforcer emits it for a + * mempool transaction too, carrying only a timestamp -- "when we saw + * it", not "when it was mined". A mined one additionally carries + * height and blockHash. Treating presence as confirmation credited + * every payout the moment it was broadcast. */ + const height = Number(info?.height ?? info?.block_height ?? 0); + const hasBlock = (info?.blockHash?.hex ?? info?.block_hash?.hex) != null; + const legacy = Number(hit.confirmations ?? hit.confirmationHeight ?? 0); + return { + confirmed: height > 0 || hasBlock || legacy > 0, + known: true, + error: null, + }; } /* payout.js cross-checks settlement against wallet outputs, because a @@ -137,7 +169,25 @@ export class EnforcerWalletClient { try { const j = await this._call('ListUnspentOutputs', {}); const rows = j.outputs || j.utxos || []; - return { ok: true, utxos: rows.map(u => ({ txid: u.txid?.hex ?? u.txid })) }; + /* CONFIRMED outputs only. payout.js treats a wallet output from + * the batch's txid as proof the batch settled, and the enforcer + * applies a transaction to its wallet the moment it broadcasts -- + * so the change output of a still-unmined payout appears here + * immediately. Counting it credited paid_sats and wrote the + * ledger row while the transaction was in the mempool, which is + * the exact thing "paid means mined, not sent" forbids: if that + * transaction is dropped, the debt is marked settled and the + * miner is never paid. + * + * unconfirmedLastSeen is present only while unmined. Anything + * carrying it is excluded. */ + const unconfirmed = (u) => + (u.unconfirmedLastSeen ?? u.unconfirmed_last_seen) != null; + return { + ok: true, + utxos: rows.filter(u => !unconfirmed(u)) + .map(u => ({ txid: u.txid?.hex ?? u.txid })), + }; } catch (e) { return { ok: false, utxos: [], error: e.message }; } diff --git a/payout/run-once.mjs b/payout/run-once.mjs index 4165c3a..9a9e166 100644 --- a/payout/run-once.mjs +++ b/payout/run-once.mjs @@ -10,6 +10,7 @@ import { loadConfig } from './lib/config.js'; import { openDb } from './lib/db.js'; import { ThunderClient } from './lib/thunder.js'; +import { EnforcerWalletClient } from './lib/enforcer-wallet.js'; import { runOnce, reportStuck } from './lib/payout.js'; const cfg = loadConfig(); @@ -21,12 +22,25 @@ const log = { error: (m) => console.error(`[error] ${m}`), }; -const db = openDb(cfg.dbPath); -const thunder = new ThunderClient({ - url: cfg.rpcUrl, - user: cfg.rpcUser, - pass: cfg.rpcPass, -}); +const db = openDb(cfg.dbPath); +/* Same rail selection as index.js, and it has to be: PAYOUT_RAIL decides + * which client can actually move the money, not merely which environment + * variables are required. Constructing a ThunderClient unconditionally here + * pointed an L1 pool at Thunder with cfg.rpcUrl === null -- the tick found + * nobody to pay and exited 0, so cron-style operators got a clean run and no + * payments. The two clients present the same interface, so nothing below + * branches. */ +const thunder = cfg.rail === 'btc' + ? new EnforcerWalletClient({ + addr: cfg.enforcerAddr, + feeRateSatPerVb: cfg.feeRateSatPerVb, + passphrase: cfg.walletPassphrase, + }) + : new ThunderClient({ + url: cfg.rpcUrl, + user: cfg.rpcUser, + pass: cfg.rpcPass, + }); reportStuck({ db }, log); const res = await runOnce({ db, thunder, cfg }, log); diff --git a/payout/test/enforcer-wallet.test.js b/payout/test/enforcer-wallet.test.js index e1150f9..25f636d 100644 --- a/payout/test/enforcer-wallet.test.js +++ b/payout/test/enforcer-wallet.test.js @@ -103,7 +103,30 @@ test('balance counts confirmed sats only', async () => { * else is how a pool promises what it cannot send. */ const c = new EnforcerWalletClient({ addr: 'x' }); stub(c, { GetBalance: { confirmedSats: 500000, pendingSats: 999999999 } }); - assert.equal(await c.balance(), 500000n); + assert.equal((await c.balance()).available_sats, 500000n); +}); + +test('balance returns the shape payout.js actually reads', async () => { + /* payout.js does `BigInt(bal.available_sats ?? bal.total_sats ?? 0)` -- + * ThunderClient's shape, and the reason the payout loop can drive either + * rail without branching. This client used to return a bare BigInt, on + * which both fields are undefined, so the reserve gate read every wallet + * as empty and refused to pay: "reserve short — available=0". Asserting + * the returned value equals a BigInt passes just fine against that, which + * is how it survived. Assert the way the caller reads it instead. */ + const c = new EnforcerWalletClient({ addr: 'x' }); + stub(c, { GetBalance: { confirmedSats: '25000000000' } }); + const bal = await c.balance(); + assert.equal(BigInt(bal.available_sats ?? bal.total_sats ?? 0), 25000000000n); +}); + +test('a balance past 2^53 survives as an exact integer', async () => { + /* The enforcer sends confirmedSats as a decimal string. Routing it + * through Number() rounds above 2^53 -- about 90,000 BTC, which a pool + * wallet can hold -- and the rounding is silently in either direction. */ + const c = new EnforcerWalletClient({ addr: 'x' }); + stub(c, { GetBalance: { confirmedSats: '9007199254740993' } }); /* 2^53 + 1 */ + assert.equal((await c.balance()).available_sats, 9007199254740993n); }); test('an unreachable node is unknown, never confirmed and never evicted', async () => { @@ -137,3 +160,67 @@ test('L1 needs no mining nudge, but answers the call payout.js makes', async () assert.equal((await c.mine()).ok, true); assert.deepEqual(await c.mempool(), { ok: true, txids: [] }); }); + +/* Settlement, as the enforcer actually reports it. + * + * These three pin the shape that made the L1 rail credit a miner the moment + * it broadcast. paid means MINED, not sent: crediting on broadcast marks the + * debt settled while the transaction can still be dropped, and there is no + * negative credit to undo it with. */ + +test('a mined transaction is confirmed, from confirmationInfo', async () => { + /* The enforcer reports confirmation as a confirmationInfo submessage, not + * as a `confirmations` count. Reading the count meant this returned + * confirmed:false for every transaction ever mined. */ + const c = new EnforcerWalletClient({ addr: 'x' }); + stub(c, { ListTransactions: { transactions: [ + { txid: { hex: 'aa' }, + confirmationInfo: { height: 812, blockHash: { hex: 'bb' } } }, + ] } }); + assert.deepEqual(await c.getTransaction('aa'), + { confirmed: true, known: true, error: null }); +}); + +test('a mempool transaction is known but not confirmed', async () => { + /* Known-and-unconfirmed is what payout.js turns into "pending", which + * blocks the next tick instead of re-broadcasting into a double spend. */ + const c = new EnforcerWalletClient({ addr: 'x' }); + stub(c, { ListTransactions: { transactions: [{ txid: { hex: 'aa' } }] } }); + assert.deepEqual(await c.getTransaction('aa'), + { confirmed: false, known: true, error: null }); +}); + +test('confirmationInfo with only a timestamp is NOT confirmation', async () => { + /* The shape a real enforcer returns for a transaction still in the + * mempool: + * + * "confirmationInfo": { "timestamp": "..." } unmined + * "confirmationInfo": { "height": 812, "blockHash": ... } mined + * + * The timestamp is when the wallet saw it, not when it was mined. Keying + * on the presence of confirmationInfo therefore reported every broadcast + * as confirmed, and paid_sats moved while the transaction could still be + * dropped. Key on height/blockHash. */ + const c = new EnforcerWalletClient({ addr: 'x' }); + stub(c, { ListTransactions: { transactions: [ + { txid: { hex: 'aa' }, confirmationInfo: { timestamp: '2026-09-07T10:02:33Z' } }, + ] } }); + assert.deepEqual(await c.getTransaction('aa'), + { confirmed: false, known: true, error: null }); +}); + +test('an unconfirmed change output is not evidence of settlement', async () => { + /* payout.js cross-checks settlement against wallet outputs, and the + * enforcer applies a transaction to its wallet as soon as it broadcasts + * it -- so the change output of an unmined payout shows up here at once. + * Counting it settled the batch on broadcast. Only confirmed outputs may + * stand as proof; unconfirmedLastSeen is present exactly while unmined. */ + const c = new EnforcerWalletClient({ addr: 'x' }); + stub(c, { ListUnspentOutputs: { outputs: [ + { txid: { hex: 'unmined' }, vout: 1, unconfirmedLastSeen: '2026-09-07T10:01:02Z' }, + { txid: { hex: 'mined' }, vout: 0 }, + ] } }); + const w = await c.walletUtxos(); + assert.equal(w.ok, true); + assert.deepEqual(w.utxos.map(u => u.txid), ['mined']); +}); diff --git a/tests/test_pplns_btc_payout_regtest.sh b/tests/test_pplns_btc_payout_regtest.sh new file mode 100755 index 0000000..b4985de --- /dev/null +++ b/tests/test_pplns_btc_payout_regtest.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +# End-to-end test of the pplns-btc payout rail: real money, on L1, through a +# real bip300301_enforcer wallet. +# +# bitcoind-patched <--RPC/ZMQ-- bip300301_enforcer (--enable-wallet) +# ^ ^ +# | the transaction lands here | WalletService/SendTransaction +# | | +# +--------------------------- payout worker (PAYOUT_RAIL=btc) +# +# tests/test_payout_regtest.sh already walks this sequence for the Thunder +# rail. This is the other one, and until now it had no coverage beyond unit +# tests against a stubbed client: nothing had ever asked a real enforcer +# wallet to build, sign and broadcast a payment, or checked that the sats +# arrived at the address a miner authorized with. +# +# The rail is the whole difference between the two pplns modes, and it is +# the half that moves money. tests/test_pplns_regtest.sh proves both modes +# credit pps_credits correctly; it stops exactly where this starts. +# +# What is asserted, in the order it has to happen: +# +# 1. a tick BROADCASTS and credits nobody. paid means mined, not sent, so +# a transaction that exists is not yet a payment. +# 2. a tick before confirmation neither credits nor re-broadcasts. This is +# the double-spend guard: the batch stays in flight and the tick blocks +# on it. +# 3. one L1 block later a tick SETTLES, the ledger row appears, paid_sats +# moves exactly once, and the in-flight row is gone. +# 4. the worker's address actually holds the sats on chain, read straight +# out of the UTXO set rather than from anything the worker wrote. +# +# (4) is the assertion that cannot be faked by a bookkeeping bug: every +# other check reads a database the payout worker itself wrote. +# +# No Thunder here -- pplns-btc pays on L1 and never touches a sidechain, so +# the stack is bitcoind plus a wallet-enabled enforcer and nothing else. +# +# Env: +# REGTEST_DIR data dir, WIPED each run (default: /.regtest-btcpay) +# REGTEST_BIN_DIR binary cache, kept across runs (default: /.regtest/bin) +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +export REGTEST_DIR="${REGTEST_DIR:-$ROOT/.regtest-btcpay}" +export REGTEST_BIN_DIR="${REGTEST_BIN_DIR:-$ROOT/.regtest/bin}" +# pplns-btc has no sidechain in it at all. +export REGTEST_SKIP_THUNDER=1 + +BIN="$REGTEST_BIN_DIR" +RPC="$ROOT/scripts/enforcer-rpc.sh" +PAYOUT_DB="/tmp/simplepool-btcpay-e2e.db" +# Mining sink; any valid regtest address works. +JUNK_ADDR="bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080" +# Where the miner is paid. Deliberately NOT an enforcer-wallet address: the +# point is that the money leaves the pool's wallet and arrives somewhere the +# pool does not control, which an address the wallet owns could not show. +WORKER_ADDR="bcrt1qzyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3lgth6c" +OWED_SATS=250000 + +PICKED="" +pick_port() { + local p + while :; do + p=$(( (RANDOM % 20000) + 20001 )) + [[ " $PICKED " == *" $p "* ]] && continue + nc -z 127.0.0.1 "$p" 2>/dev/null && continue + PICKED="$PICKED $p" + printf -v "$1" '%s' "$p" + return + done +} + +cli() { "$BIN/bitcoin-cli" -datadir="$REGTEST_DIR/data/bitcoind" -regtest \ + -rpcuser=user -rpcpassword=password "$@"; } +stage() { echo; echo "=== btcpay-e2e: $1"; } + +dump_logs() { + echo "!!! btcpay-e2e FAILED — recent logs:" >&2 + for f in "$REGTEST_DIR"/logs/*.log; do + [ -f "$f" ] || continue + echo "--- tail $f" >&2 + tail -40 "$f" >&2 + done +} + +cleanup() { + "$ROOT/scripts/regtest/stop.sh" || true + rm -rf "$LOCK" +} + +LOCK="$REGTEST_DIR.lock" +if ! mkdir "$LOCK" 2>/dev/null; then + echo "FAIL: $LOCK exists — another run of this suite is active." >&2 + echo "If it crashed and left the lock behind, clear it with:" >&2 + echo " REGTEST_DIR=$REGTEST_DIR scripts/regtest/stop.sh && rm -rf $LOCK" >&2 + exit 1 +fi +trap 'code=$?; [ "$code" -ne 0 ] && dump_logs; cleanup; exit $code' EXIT +trap 'exit 130' INT TERM + +for dep in sqlite3 jq node nc curl; do + command -v "$dep" >/dev/null 2>&1 || { echo "$dep not installed" >&2; exit 1; } +done + +stage "allocate stack ports" +pick_port REGTEST_BITCOIND_RPC_PORT +pick_port REGTEST_BITCOIND_ZMQ_PORT +pick_port REGTEST_ENFORCER_RPC_PORT +pick_port REGTEST_ENFORCER_GRPC_PORT +export REGTEST_BITCOIND_RPC_PORT REGTEST_BITCOIND_ZMQ_PORT \ + REGTEST_ENFORCER_RPC_PORT REGTEST_ENFORCER_GRPC_PORT +export ENFORCER_URL="http://127.0.0.1:$REGTEST_ENFORCER_GRPC_PORT" +# What the payout worker is handed. host:port, no scheme -- enforcer-rpc.js +# adds http:// when there is none. +ENFORCER_RPC_ADDR="127.0.0.1:$REGTEST_ENFORCER_GRPC_PORT" +echo " bitcoind=$REGTEST_BITCOIND_RPC_PORT enforcer=$REGTEST_ENFORCER_RPC_PORT/$REGTEST_ENFORCER_GRPC_PORT" + +stage "wipe data dir (fresh chain every run)" +rm -rf "$REGTEST_DIR/data" "$REGTEST_DIR/logs" "$REGTEST_DIR/run" + +stage "download prebuilt binaries" +"$ROOT/scripts/regtest/setup.sh" + +stage "install payout worker deps" +if [ ! -d "$ROOT/payout/node_modules/better-sqlite3" ]; then + npm ci --prefix "$ROOT/payout" --no-audit --no-fund +fi + +stage "start bitcoind-patched + enforcer (wallet enabled)" +"$ROOT/scripts/regtest/start.sh" + +stage "fund the enforcer wallet" +# The pool holds no keys: pplns-btc pays by asking the enforcer's wallet to +# send, so that wallet is what has to have spendable coins. +ENF_ADDR="$("$RPC" cusf.mainchain.v1.WalletService/CreateNewAddress | jq -r .address)" +echo " enforcer wallet address: $ENF_ADDR" +RPC_TIMEOUT=120 "$RPC" cusf.mainchain.v1.MiningService/GenerateToAddress \ + '{"blocks": 5, "address": "'"$ENF_ADDR"'"}' > /dev/null +# Past coinbase maturity (100), or there is nothing spendable to pay from. +RPC_TIMEOUT=300 "$RPC" cusf.mainchain.v1.MiningService/GenerateToAddress \ + '{"blocks": 100, "address": "'"$JUNK_ADDR"'"}' > /dev/null +BAL="$("$RPC" cusf.mainchain.v1.WalletService/GetBalance | jq -r '.confirmedSats // .confirmed_sats // 0')" +echo " height=$(cli getblockcount) enforcer confirmed balance=$BAL sats" +[ "${BAL:-0}" -gt "$OWED_SATS" ] || { + echo "FAIL: enforcer wallet has $BAL sats, needs more than $OWED_SATS" >&2; exit 1; } + +stage "seed pool DB: one worker owed $OWED_SATS sats, payable on L1" +rm -f "$PAYOUT_DB" "$PAYOUT_DB-wal" "$PAYOUT_DB-shm" +NOW="$(date +%s)" +sqlite3 "$PAYOUT_DB" < "$ROOT/schema.sql" +sqlite3 "$PAYOUT_DB" " + INSERT INTO workers (name, first_seen, last_seen, payout_address) + VALUES ('${WORKER_ADDR}.rig1', $NOW, $NOW, '$WORKER_ADDR'); + INSERT INTO pps_credits (worker_id, accrued_sats, paid_sats, last_updated) + VALUES (1, $OWED_SATS, 0, $NOW); +" + +# PAYOUT_RAIL=btc is what selects this client, and with it the enforcer +# variables become the required set and the Thunder ones are not read at +# all -- a correctly configured L1 pool must not be refused for lacking +# THUNDER_RPC_URL. That this tick runs with none of them set is that check. +run_tick() { + PAYOUT_DB_PATH="$PAYOUT_DB" \ + PAYOUT_RAIL=btc \ + ENFORCER_RPC_ADDR="$ENFORCER_RPC_ADDR" \ + PAYOUT_FEE_RATE_SAT_VB=2 \ + PAYOUT_MIN_SATS=10000 \ + node "$ROOT/payout/run-once.mjs" +} + +paid_sats() { sqlite3 "$PAYOUT_DB" "SELECT paid_sats FROM pps_credits WHERE worker_id = 1"; } +ledger_n() { sqlite3 "$PAYOUT_DB" "SELECT count(*) FROM payouts"; } +inflight_n() { sqlite3 "$PAYOUT_DB" "SELECT count(*) FROM payouts_in_flight"; } + +stage "payout tick 1: broadcast only" +RESULT="$(run_tick)" || { echo "FAIL: payout tick reported failures: $RESULT" >&2; exit 1; } +echo " tick result: $RESULT" +[ "$(jq -r .broadcast <<< "$RESULT")" = "1" ] || { + echo "FAIL: expected exactly 1 broadcast worker" >&2; exit 1; } +# paid means mined, not sent. A transaction that merely exists is not a +# payment, and crediting here is how a pool pays twice for one debt. +[ "$(jq -r .paid <<< "$RESULT")" = "0" ] || { + echo "FAIL: a broadcast must not report a paid worker" >&2; exit 1; } + +stage "assert the broadcast credited nobody" +TXID="$(sqlite3 "$PAYOUT_DB" "SELECT txid FROM payouts_in_flight WHERE worker_id = 1")" +echo " txid=$TXID paid_sats=$(paid_sats) ledger_rows=$(ledger_n) in_flight=$(inflight_n)" +[ "${#TXID}" -eq 64 ] || { echo "FAIL: bad in-flight txid '$TXID'" >&2; exit 1; } +[ "$(paid_sats)" = "0" ] || { echo "FAIL: paid_sats moved on a broadcast" >&2; exit 1; } +[ "$(ledger_n)" = "0" ] || { echo "FAIL: payouts ledger written before confirmation" >&2; exit 1; } +[ "$(inflight_n)" = "1" ] || { echo "FAIL: batch must stay in flight until mined" >&2; exit 1; } + +stage "assert bitcoind holds the tx, unconfirmed" +# Straight from the node's mempool, not from the enforcer that sent it. +cli getrawtransaction "$TXID" true > /dev/null 2>&1 || { + echo "FAIL: bitcoind does not know $TXID — it was never broadcast" >&2 + cli getrawmempool >&2 || true + exit 1; } +cli getrawtransaction "$TXID" true | jq -e '.blockhash == null' > /dev/null || { + echo "FAIL: $TXID is already confirmed; the sequence below tests nothing" >&2; exit 1; } +echo " bitcoind has $TXID in its mempool, unconfirmed" + +stage "a tick before confirmation must not credit or re-broadcast" +RESULT="$(run_tick)" || { echo "FAIL: payout tick reported failures: $RESULT" >&2; exit 1; } +echo " tick result: $RESULT" +[ "$(jq -r .waiting_on <<< "$RESULT")" = "$TXID" ] || { + echo "FAIL: expected the tick to wait on $TXID" >&2; exit 1; } +[ "$(paid_sats)" = "0" ] || { echo "FAIL: credited before the tx was mined" >&2; exit 1; } +[ "$(inflight_n)" = "1" ] || { echo "FAIL: batch left flight before confirming" >&2; exit 1; } + +stage "mine one L1 block, then settle" +# Unlike Thunder, Bitcoin needs no nudging to include a transaction -- the +# client's mine() is a deliberate no-op. One block is the whole difference +# between broadcast and paid. +for attempt in 1 2 3 4 5; do + RPC_TIMEOUT=120 "$RPC" cusf.mainchain.v1.MiningService/GenerateToAddress \ + '{"blocks": 1, "address": "'"$JUNK_ADDR"'"}' > /dev/null + RESULT="$(run_tick)" || { echo "FAIL: payout tick reported failures: $RESULT" >&2; exit 1; } + echo " attempt $attempt: $RESULT" + [ "$(jq -r .settled <<< "$RESULT")" = "1" ] && break + sleep 1 +done +[ "$(jq -r .settled <<< "$RESULT")" = "1" ] || { + echo "FAIL: payout never settled after 5 L1 blocks" >&2; exit 1; } + +stage "assert the ledger settled" +LEDGER_TXID="$(sqlite3 "$PAYOUT_DB" "SELECT txid FROM payouts WHERE worker_id = 1")" +echo " txid=$LEDGER_TXID paid_sats=$(paid_sats) in_flight=$(inflight_n)" +[ "$LEDGER_TXID" = "$TXID" ] || { echo "FAIL: ledger txid '$LEDGER_TXID' != '$TXID'" >&2; exit 1; } +[ "$(paid_sats)" = "$OWED_SATS" ] || { echo "FAIL: paid_sats=$(paid_sats) != $OWED_SATS" >&2; exit 1; } +[ "$(inflight_n)" = "0" ] || { echo "FAIL: $(inflight_n) in-flight rows left" >&2; exit 1; } + +stage "assert the sats are really at the worker's address" +# The one check that reads neither the payout worker's database nor the +# wallet that sent the money: an unspent output of exactly OWED_SATS at the +# miner's own address, straight out of the chain's UTXO set. Every other +# assertion above would still pass if the ledger were being written without +# a payment behind it. +SCAN="$(cli scantxoutset start '["addr('"$WORKER_ADDR"')"]')" +FOUND_SATS="$(jq -r '[.unspents[].amount] | add // 0 | . * 100000000 | round' <<< "$SCAN")" +echo " utxo set holds $FOUND_SATS sats at $WORKER_ADDR" +[ "$FOUND_SATS" = "$OWED_SATS" ] || { + echo "FAIL: expected $OWED_SATS sats at $WORKER_ADDR, found $FOUND_SATS" >&2 + jq . <<< "$SCAN" >&2 || true + exit 1; } + +# A second settled tick must not pay again. paid_sats is the latch, and the +# failure it guards against leaves no trace in the amounts themselves. +stage "a further tick pays nothing more" +RESULT="$(run_tick)" || { echo "FAIL: payout tick reported failures: $RESULT" >&2; exit 1; } +echo " tick result: $RESULT" +[ "$(paid_sats)" = "$OWED_SATS" ] || { + echo "FAIL: paid_sats moved to $(paid_sats) on a tick with nothing owed" >&2; exit 1; } +[ "$(ledger_n)" = "1" ] || { + echo "FAIL: $(ledger_n) ledger rows for one payment" >&2; exit 1; } + +echo +echo "btcpay-e2e: PASS (pplns-btc paid a miner on L1 through the enforcer wallet)" From c33590b77fce9a5b1689323a8f7b7f98b4bf8354 Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 7 Sep 2026 12:29:13 +0200 Subject: [PATCH 10/18] Pay every due address in one L1 transaction, and prove it with three miners The single-recipient run could not reach either of the two things a real pool does on every payout: send to many addresses at once, and handle two rigs that authorized with the same one. Extending it to three workers, two of them sharing an address, found a fourth bug in the rail. payout.js decides whether it may batch across addresses by LEARNING what a previous transfer turned out to do, and deliberately treats a node it has not yet proven as one that cannot -- for Thunder the answer genuinely varies, and guessing wrong spends somebody else's balance. The enforcer client returns no broadcastByNode, so it never proved anything, and the first tick of every process paid one address and deferred the rest: payout: 2 due ... (this node cannot batch across addresses, so paying bcrt1qyg3z... now; 1 more address(es) follow on later ticks) There is nothing to learn. WalletService/SendTransaction takes a destinations map -- paying many addresses at once is the shape of the call. And run-once.mjs is one process per tick, so under cron every tick is a first tick: one address per run, a separate fee each, and a pool with fifty miner addresses taking fifty daily ticks to pay everyone once. The client now declares batchesAcrossAddresses and payout.js believes a client that knows, while still making a node whose behaviour has to be discovered prove it. Thunder's path is untouched: it has no such property, so it learns exactly as before, and its e2e still passes. The test now asserts what only a multi-recipient batch can show: - all three due workers leave in ONE transaction (three in-flight rows, one distinct txid) rather than one transaction and one fee each - the transaction pays the shared address ONCE, carrying 300000 -- the sum of both rigs' debts, which is neither operand, so no assertion can pass by coincidence. Two outputs would mean it was written twice; the wrong value would mean one entry overwrote the other, paying that miner once for two debts while the ledger marked both settled - each rig is still credited its OWN debt (180000 and 120000) even though a single output covered both; crediting the merged amount to either would leave the other owed forever - the addresses really hold 250000 and 300000 in the chain's UTXO set, read from neither the payout worker's database nor the wallet that sent the money Read off the wire before the block, so the output shape is checked against the transaction itself rather than against anything either side recorded. payout 85, dashboard 138, Thunder payout e2e still green. --- payout/lib/enforcer-wallet.js | 16 +++ payout/lib/payout.js | 9 +- payout/test/enforcer-wallet.test.js | 29 ++++ tests/test_pplns_btc_payout_regtest.sh | 186 +++++++++++++++++-------- 4 files changed, 179 insertions(+), 61 deletions(-) diff --git a/payout/lib/enforcer-wallet.js b/payout/lib/enforcer-wallet.js index fcf687a..5bb57c5 100644 --- a/payout/lib/enforcer-wallet.js +++ b/payout/lib/enforcer-wallet.js @@ -32,6 +32,22 @@ function safeNumber(v, what) { } export class EnforcerWalletClient { + /* Every due worker goes out in one transaction, whatever their addresses. + * + * payout.js otherwise has to LEARN this, from what a previous transfer + * turned out to do, and treats a node it has not yet proven as one that + * cannot batch -- because for Thunder the answer genuinely varies, and + * guessing wrong spends somebody else's balance. There is nothing to + * learn here: WalletService/SendTransaction takes a destinations map, so + * paying many addresses at once is the shape of the call itself. + * + * Left to be learned, the first tick of every process pays one address + * and defers the rest. run-once.mjs is one process per tick, so under + * cron that is EVERY tick: one address per run, a separate fee each, and + * a pool with fifty miner addresses taking fifty daily ticks to pay + * everyone once. */ + batchesAcrossAddresses = true; + /* feeRateSatPerVb is passed straight through to the enforcer, which does * the fee arithmetic. There is no local estimator to drift out of date. */ constructor({ addr, feeRateSatPerVb = 5, passphrase = null, timeoutMs = 30_000 }) { diff --git a/payout/lib/payout.js b/payout/lib/payout.js index 79240ca..1669be1 100644 --- a/payout/lib/payout.js +++ b/payout/lib/payout.js @@ -320,7 +320,14 @@ export async function runOnce(ctx, log) { * question is itself a transfer. One address goes out first; the answer * arrives with it. */ const groups = groupByAddress(allDue); - const canBatchAcrossAddresses = ctx._nodeBroadcastsOnCreate === false; + /* A client that KNOWS it can batch says so and is believed; only a node + * whose behaviour has to be discovered is made to prove it. The enforcer + * wallet is the former -- SendTransaction takes a destinations map, so + * there is nothing to find out and nothing a probe could add. Making it + * learn instead meant every first tick paid one address and deferred the + * rest, and run-once.mjs is a fresh process per tick. */ + const canBatchAcrossAddresses = + thunder.batchesAcrossAddresses === true || ctx._nodeBroadcastsOnCreate === false; const due = canBatchAcrossAddresses ? allDue : groups[0].rows; const queued = canBatchAcrossAddresses ? 0 : groups.length - 1; diff --git a/payout/test/enforcer-wallet.test.js b/payout/test/enforcer-wallet.test.js index 25f636d..a4e0943 100644 --- a/payout/test/enforcer-wallet.test.js +++ b/payout/test/enforcer-wallet.test.js @@ -224,3 +224,32 @@ test('an unconfirmed change output is not evidence of settlement', async () => { assert.equal(w.ok, true); assert.deepEqual(w.utxos.map(u => u.txid), ['mined']); }); + +test('the L1 client declares that it batches across addresses', async () => { + /* payout.js otherwise learns this from what a previous transfer did, and + * treats an unproven node as one that cannot batch -- so the first tick of + * every process pays one address and defers the rest. run-once.mjs is one + * process per tick, so under cron every tick is a first tick: one address + * per run, a separate fee each. + * + * There is nothing to learn. SendTransaction takes a destinations map; + * paying many addresses at once is the shape of the call. */ + const c = new EnforcerWalletClient({ addr: 'x' }); + assert.equal(c.batchesAcrossAddresses, true); +}); + +test('one SendTransaction carries every address in the batch', async () => { + /* And the shared address is summed into a single destination rather than + * one entry overwriting the other. */ + const c = new EnforcerWalletClient({ addr: 'x' }); + const calls = stub(c, { SendTransaction: { txid: { hex: 'deadbeef' } } }); + const res = await c.transferBatchDetailed([ + { address: 'addr_a', sats: 250000n }, + { address: 'addr_b', sats: 180000n }, + { address: 'addr_b', sats: 120000n }, + ]); + const sends = calls.filter(x => x.method === 'SendTransaction'); + assert.equal(sends.length, 1, 'one transaction for the whole batch'); + assert.deepEqual(sends[0].body.destinations, { addr_a: 250000, addr_b: 300000 }); + assert.equal(res.txid, 'deadbeef'); +}); diff --git a/tests/test_pplns_btc_payout_regtest.sh b/tests/test_pplns_btc_payout_regtest.sh index b4985de..63aab57 100755 --- a/tests/test_pplns_btc_payout_regtest.sh +++ b/tests/test_pplns_btc_payout_regtest.sh @@ -22,17 +22,25 @@ # # 1. a tick BROADCASTS and credits nobody. paid means mined, not sent, so # a transaction that exists is not yet a payment. -# 2. a tick before confirmation neither credits nor re-broadcasts. This is +# 2. all three due workers leave in ONE transaction, and the two sharing a +# payout address get ONE output carrying the SUM of both debts. Read off +# the wire before it is mined. +# 3. a tick before confirmation neither credits nor re-broadcasts. This is # the double-spend guard: the batch stays in flight and the tick blocks # on it. -# 3. one L1 block later a tick SETTLES, the ledger row appears, paid_sats -# moves exactly once, and the in-flight row is gone. -# 4. the worker's address actually holds the sats on chain, read straight +# 4. one L1 block later a tick SETTLES, a ledger row appears per worker +# against the one txid, each rig is credited its OWN debt, and the +# in-flight rows are gone. +# 5. the miners' addresses actually hold the sats on chain, read straight # out of the UTXO set rather than from anything the worker wrote. # -# (4) is the assertion that cannot be faked by a bookkeeping bug: every +# (5) is the assertion that cannot be faked by a bookkeeping bug: every # other check reads a database the payout worker itself wrote. # +# Three workers rather than one because a single recipient never builds a +# multi-destination transaction, which is what every real pool sends, and +# never exercises the address merge at all. +# # No Thunder here -- pplns-btc pays on L1 and never touches a sidechain, so # the stack is bitcoind plus a wallet-enabled enforcer and nothing else. # @@ -53,11 +61,21 @@ RPC="$ROOT/scripts/enforcer-rpc.sh" PAYOUT_DB="/tmp/simplepool-btcpay-e2e.db" # Mining sink; any valid regtest address works. JUNK_ADDR="bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080" -# Where the miner is paid. Deliberately NOT an enforcer-wallet address: the +# Where the miners are paid. Deliberately NOT enforcer-wallet addresses: the # point is that the money leaves the pool's wallet and arrives somewhere the # pool does not control, which an address the wallet owns could not show. -WORKER_ADDR="bcrt1qzyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3lgth6c" -OWED_SATS=250000 +# +# Two addresses, three workers: rig2 authorizes with the same address as +# rig1, which is ordinary (one miner, two machines) and is the case the +# destinations map has to merge rather than overwrite. +ADDR_A="bcrt1qzyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3lgth6c" +ADDR_B="bcrt1qyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zs4w3j0" +OWED_A=250000 +OWED_B1=180000 +OWED_B2=120000 +# Distinct per worker and distinct in sum, so no assertion below can pass by +# coincidence: 180000+120000 = 300000, which is neither operand. +OWED_SATS=$((OWED_A + OWED_B1 + OWED_B2)) PICKED="" pick_port() { @@ -146,16 +164,37 @@ echo " height=$(cli getblockcount) enforcer confirmed balance=$BAL sats" [ "${BAL:-0}" -gt "$OWED_SATS" ] || { echo "FAIL: enforcer wallet has $BAL sats, needs more than $OWED_SATS" >&2; exit 1; } -stage "seed pool DB: one worker owed $OWED_SATS sats, payable on L1" +stage "seed pool DB: three workers, two of them sharing one payout address" +# The production-normal case, and the one the single-recipient run could not +# reach. Two things only a multi-recipient batch can show: +# +# 1. every due worker leaves in ONE transaction, not one each. A payout per +# worker is a fee per worker, and it breaks the one-tx-per-batch +# invariant the whole at-most-once design rests on. +# 2. two rigs authorized with the SAME payout address are SUMMED. The +# destinations map is keyed by address, so an assignment instead of a +# sum pays that miner once for two debts while the ledger marks both +# settled -- a shortfall that balances perfectly on the pool's side and +# is visible only to the miner. groupByAddress in payout.js merges them +# and the client sums again; this proves the pair against a real wallet +# rather than against a stub. rm -f "$PAYOUT_DB" "$PAYOUT_DB-wal" "$PAYOUT_DB-shm" NOW="$(date +%s)" sqlite3 "$PAYOUT_DB" < "$ROOT/schema.sql" sqlite3 "$PAYOUT_DB" " - INSERT INTO workers (name, first_seen, last_seen, payout_address) - VALUES ('${WORKER_ADDR}.rig1', $NOW, $NOW, '$WORKER_ADDR'); - INSERT INTO pps_credits (worker_id, accrued_sats, paid_sats, last_updated) - VALUES (1, $OWED_SATS, 0, $NOW); + INSERT INTO workers (id, name, first_seen, last_seen, payout_address) VALUES + (1, '${ADDR_A}.rig1', $NOW, $NOW, '$ADDR_A'), + (2, '${ADDR_B}.rig1', $NOW, $NOW, '$ADDR_B'), + (3, '${ADDR_B}.rig2', $NOW, $NOW, '$ADDR_B'); + INSERT INTO pps_credits (worker_id, accrued_sats, paid_sats, last_updated) VALUES + (1, $OWED_A, 0, $NOW), + (2, $OWED_B1, 0, $NOW), + (3, $OWED_B2, 0, $NOW); " +echo " worker 1 -> $ADDR_A $OWED_A sats" +echo " worker 2 -> $ADDR_B $OWED_B1 sats" +echo " worker 3 -> $ADDR_B $OWED_B2 sats (same address as worker 2)" +echo " expected on chain: $OWED_A at A, $((OWED_B1 + OWED_B2)) at B" # PAYOUT_RAIL=btc is what selects this client, and with it the enforcer # variables become the required set and the Thunder ones are not read at @@ -170,45 +209,65 @@ run_tick() { node "$ROOT/payout/run-once.mjs" } -paid_sats() { sqlite3 "$PAYOUT_DB" "SELECT paid_sats FROM pps_credits WHERE worker_id = 1"; } +paid_of() { sqlite3 "$PAYOUT_DB" "SELECT paid_sats FROM pps_credits WHERE worker_id = $1"; } +paid_total() { sqlite3 "$PAYOUT_DB" "SELECT COALESCE(SUM(paid_sats),0) FROM pps_credits"; } ledger_n() { sqlite3 "$PAYOUT_DB" "SELECT count(*) FROM payouts"; } inflight_n() { sqlite3 "$PAYOUT_DB" "SELECT count(*) FROM payouts_in_flight"; } +# Sats currently unspent at an address, straight out of the chain's UTXO set. +utxo_sats() { cli scantxoutset start '["addr('"$1"')"]' \ + | jq -r '[.unspents[].amount] | add // 0 | . * 100000000 | round'; } stage "payout tick 1: broadcast only" RESULT="$(run_tick)" || { echo "FAIL: payout tick reported failures: $RESULT" >&2; exit 1; } echo " tick result: $RESULT" -[ "$(jq -r .broadcast <<< "$RESULT")" = "1" ] || { - echo "FAIL: expected exactly 1 broadcast worker" >&2; exit 1; } -# paid means mined, not sent. A transaction that merely exists is not a -# payment, and crediting here is how a pool pays twice for one debt. +[ "$(jq -r .broadcast <<< "$RESULT")" = "3" ] || { + echo "FAIL: expected 3 broadcast workers, got $(jq -r .broadcast <<< "$RESULT")" >&2; exit 1; } +# paid means mined, not sent. A transaction that exists is not yet a payment. [ "$(jq -r .paid <<< "$RESULT")" = "0" ] || { echo "FAIL: a broadcast must not report a paid worker" >&2; exit 1; } -stage "assert the broadcast credited nobody" -TXID="$(sqlite3 "$PAYOUT_DB" "SELECT txid FROM payouts_in_flight WHERE worker_id = 1")" -echo " txid=$TXID paid_sats=$(paid_sats) ledger_rows=$(ledger_n) in_flight=$(inflight_n)" +stage "assert all three left in ONE transaction" +TXIDS="$(sqlite3 "$PAYOUT_DB" "SELECT DISTINCT txid FROM payouts_in_flight")" +TXID="$(head -1 <<< "$TXIDS")" +echo " in_flight=$(inflight_n) distinct txids=$(wc -l <<< "$TXIDS" | tr -d ' ') txid=$TXID" +[ "$(inflight_n)" = "3" ] || { echo "FAIL: expected 3 in-flight rows" >&2; exit 1; } +[ "$(wc -l <<< "$TXIDS" | tr -d ' ')" = "1" ] || { + echo "FAIL: the batch went out as more than one transaction:" >&2 + echo "$TXIDS" >&2; exit 1; } [ "${#TXID}" -eq 64 ] || { echo "FAIL: bad in-flight txid '$TXID'" >&2; exit 1; } -[ "$(paid_sats)" = "0" ] || { echo "FAIL: paid_sats moved on a broadcast" >&2; exit 1; } +[ "$(paid_total)" = "0" ] || { echo "FAIL: paid_sats moved on a broadcast" >&2; exit 1; } [ "$(ledger_n)" = "0" ] || { echo "FAIL: payouts ledger written before confirmation" >&2; exit 1; } -[ "$(inflight_n)" = "1" ] || { echo "FAIL: batch must stay in flight until mined" >&2; exit 1; } -stage "assert bitcoind holds the tx, unconfirmed" -# Straight from the node's mempool, not from the enforcer that sent it. -cli getrawtransaction "$TXID" true > /dev/null 2>&1 || { - echo "FAIL: bitcoind does not know $TXID — it was never broadcast" >&2 - cli getrawmempool >&2 || true +stage "assert the transaction pays each address once, at the summed amount" +# Read straight off the wire, before it is mined: two miner outputs, not +# three. Three would mean the shared address was written twice; one at the +# wrong value would mean it was overwritten rather than summed. +RAW="$(cli getrawtransaction "$TXID" true)" +A_OUT="$(jq -r --arg a "$ADDR_A" '[.vout[] | select(.scriptPubKey.address == $a) | .value * 100000000 | round] | add // 0' <<< "$RAW")" +B_OUT="$(jq -r --arg b "$ADDR_B" '[.vout[] | select(.scriptPubKey.address == $b) | .value * 100000000 | round] | add // 0' <<< "$RAW")" +B_N="$(jq -r --arg b "$ADDR_B" '[.vout[] | select(.scriptPubKey.address == $b)] | length' <<< "$RAW")" +echo " outputs: A=$A_OUT sats B=$B_OUT sats across $B_N output(s)" +[ "$A_OUT" = "$OWED_A" ] || { + echo "FAIL: worker A output is $A_OUT, expected $OWED_A" >&2; exit 1; } +[ "$B_OUT" = "$((OWED_B1 + OWED_B2))" ] || { + echo "FAIL: the shared address got $B_OUT, expected $((OWED_B1 + OWED_B2))." >&2 + echo " Two rigs on one address must be SUMMED, not overwritten — this" >&2 + echo " is the shortfall that balances on the pool's side and is" >&2 + echo " visible only to the miner." >&2 exit 1; } -cli getrawtransaction "$TXID" true | jq -e '.blockhash == null' > /dev/null || { +[ "$B_N" = "1" ] || { + echo "FAIL: the shared address appears in $B_N outputs; the destinations" >&2 + echo " map should have merged them into one" >&2; exit 1; } +jq -e '.blockhash == null' <<< "$RAW" > /dev/null || { echo "FAIL: $TXID is already confirmed; the sequence below tests nothing" >&2; exit 1; } -echo " bitcoind has $TXID in its mempool, unconfirmed" stage "a tick before confirmation must not credit or re-broadcast" RESULT="$(run_tick)" || { echo "FAIL: payout tick reported failures: $RESULT" >&2; exit 1; } echo " tick result: $RESULT" [ "$(jq -r .waiting_on <<< "$RESULT")" = "$TXID" ] || { echo "FAIL: expected the tick to wait on $TXID" >&2; exit 1; } -[ "$(paid_sats)" = "0" ] || { echo "FAIL: credited before the tx was mined" >&2; exit 1; } -[ "$(inflight_n)" = "1" ] || { echo "FAIL: batch left flight before confirming" >&2; exit 1; } +[ "$(paid_total)" = "0" ] || { echo "FAIL: credited before the tx was mined" >&2; exit 1; } +[ "$(inflight_n)" = "3" ] || { echo "FAIL: batch left flight before confirming" >&2; exit 1; } stage "mine one L1 block, then settle" # Unlike Thunder, Bitcoin needs no nudging to include a transaction -- the @@ -219,42 +278,49 @@ for attempt in 1 2 3 4 5; do '{"blocks": 1, "address": "'"$JUNK_ADDR"'"}' > /dev/null RESULT="$(run_tick)" || { echo "FAIL: payout tick reported failures: $RESULT" >&2; exit 1; } echo " attempt $attempt: $RESULT" - [ "$(jq -r .settled <<< "$RESULT")" = "1" ] && break + [ "$(jq -r .settled <<< "$RESULT")" = "3" ] && break sleep 1 done -[ "$(jq -r .settled <<< "$RESULT")" = "1" ] || { - echo "FAIL: payout never settled after 5 L1 blocks" >&2; exit 1; } +[ "$(jq -r .settled <<< "$RESULT")" = "3" ] || { + echo "FAIL: expected 3 settled workers, got $(jq -r .settled <<< "$RESULT")" >&2; exit 1; } -stage "assert the ledger settled" -LEDGER_TXID="$(sqlite3 "$PAYOUT_DB" "SELECT txid FROM payouts WHERE worker_id = 1")" -echo " txid=$LEDGER_TXID paid_sats=$(paid_sats) in_flight=$(inflight_n)" -[ "$LEDGER_TXID" = "$TXID" ] || { echo "FAIL: ledger txid '$LEDGER_TXID' != '$TXID'" >&2; exit 1; } -[ "$(paid_sats)" = "$OWED_SATS" ] || { echo "FAIL: paid_sats=$(paid_sats) != $OWED_SATS" >&2; exit 1; } -[ "$(inflight_n)" = "0" ] || { echo "FAIL: $(inflight_n) in-flight rows left" >&2; exit 1; } +stage "assert the ledger settled, per worker" +echo " paid: w1=$(paid_of 1) w2=$(paid_of 2) w3=$(paid_of 3) ledger_rows=$(ledger_n) in_flight=$(inflight_n)" +[ "$(paid_of 1)" = "$OWED_A" ] || { echo "FAIL: worker 1 paid_sats=$(paid_of 1) != $OWED_A" >&2; exit 1; } +# Each of the two rigs is credited its OWN debt, even though one transaction +# output covered both. Crediting the merged amount to either one would leave +# the other owed forever. +[ "$(paid_of 2)" = "$OWED_B1" ] || { echo "FAIL: worker 2 paid_sats=$(paid_of 2) != $OWED_B1" >&2; exit 1; } +[ "$(paid_of 3)" = "$OWED_B2" ] || { echo "FAIL: worker 3 paid_sats=$(paid_of 3) != $OWED_B2" >&2; exit 1; } +[ "$(ledger_n)" = "3" ] || { echo "FAIL: expected 3 ledger rows, got $(ledger_n)" >&2; exit 1; } +[ "$(inflight_n)" = "0" ] || { echo "FAIL: $(inflight_n) in-flight rows left" >&2; exit 1; } +LEDGER_TXIDS="$(sqlite3 "$PAYOUT_DB" "SELECT DISTINCT txid FROM payouts")" +[ "$LEDGER_TXIDS" = "$TXID" ] || { + echo "FAIL: ledger txids '$LEDGER_TXIDS' != the one broadcast '$TXID'" >&2; exit 1; } -stage "assert the sats are really at the worker's address" +stage "assert the sats are really at the miners' addresses" # The one check that reads neither the payout worker's database nor the -# wallet that sent the money: an unspent output of exactly OWED_SATS at the -# miner's own address, straight out of the chain's UTXO set. Every other -# assertion above would still pass if the ledger were being written without -# a payment behind it. -SCAN="$(cli scantxoutset start '["addr('"$WORKER_ADDR"')"]')" -FOUND_SATS="$(jq -r '[.unspents[].amount] | add // 0 | . * 100000000 | round' <<< "$SCAN")" -echo " utxo set holds $FOUND_SATS sats at $WORKER_ADDR" -[ "$FOUND_SATS" = "$OWED_SATS" ] || { - echo "FAIL: expected $OWED_SATS sats at $WORKER_ADDR, found $FOUND_SATS" >&2 - jq . <<< "$SCAN" >&2 || true - exit 1; } +# wallet that sent the money, straight out of the chain's UTXO set. Every +# other assertion above would still pass if the ledger were being written +# without a payment behind it. +A_SATS="$(utxo_sats "$ADDR_A")" +B_SATS="$(utxo_sats "$ADDR_B")" +echo " utxo set: $A_SATS at A, $B_SATS at B" +[ "$A_SATS" = "$OWED_A" ] || { + echo "FAIL: expected $OWED_A sats at $ADDR_A, found $A_SATS" >&2; exit 1; } +[ "$B_SATS" = "$((OWED_B1 + OWED_B2))" ] || { + echo "FAIL: expected $((OWED_B1 + OWED_B2)) sats at $ADDR_B, found $B_SATS" >&2; exit 1; } -# A second settled tick must not pay again. paid_sats is the latch, and the -# failure it guards against leaves no trace in the amounts themselves. stage "a further tick pays nothing more" +# paid_sats is the latch, and the failure it guards against leaves no trace +# in the amounts themselves. RESULT="$(run_tick)" || { echo "FAIL: payout tick reported failures: $RESULT" >&2; exit 1; } echo " tick result: $RESULT" -[ "$(paid_sats)" = "$OWED_SATS" ] || { - echo "FAIL: paid_sats moved to $(paid_sats) on a tick with nothing owed" >&2; exit 1; } -[ "$(ledger_n)" = "1" ] || { - echo "FAIL: $(ledger_n) ledger rows for one payment" >&2; exit 1; } +[ "$(paid_total)" = "$((OWED_A + OWED_B1 + OWED_B2))" ] || { + echo "FAIL: paid total moved to $(paid_total) on a tick with nothing owed" >&2; exit 1; } +[ "$(ledger_n)" = "3" ] || { + echo "FAIL: $(ledger_n) ledger rows for three payments" >&2; exit 1; } echo -echo "btcpay-e2e: PASS (pplns-btc paid a miner on L1 through the enforcer wallet)" +echo "btcpay-e2e: PASS (pplns-btc paid three miners on L1 in one transaction," +echo " with the shared address summed)" From 7c405414ca4ec769061705c9f4faa717d53ee7c0 Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 7 Sep 2026 12:36:54 +0200 Subject: [PATCH 11/18] Say that the templates table is load-bearing, not display-only The trim comment claimed "nothing but the dashboard reads this table, so a dropped row costs visibility and nothing else". That stopped being true when block reconciliation started reading it. On a backend serving no getblockhash -- which is every enforcer, and so the production configuration -- store_reconcile_blocks_from_templates() confirms a block by finding the template at height+1 whose prev_hash is that block. Trim that row and the block stops being confirmable: its confirmations freeze where they were, and under pplns a block frozen short of maturity is never distributed and its miners are never paid. No code change. The default retention is 30 days against a ~17-hour maturity, so the margin is wide -- but it is a margin, not an absence of coupling, and the comment was actively telling the next person that lowering templates_retention_days costs only dashboard history. --- src/store.c | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/store.c b/src/store.c index d291140..861f85f 100644 --- a/src/store.c +++ b/src/store.c @@ -1793,8 +1793,18 @@ int store_record_template(store_t *s, const store_template_t *t) { /* Trim history on the way out. Driven off the template's own timestamp * rather than wall-clock time so a replay or a test is deterministic. - * Nothing but the dashboard reads this table, so a dropped row costs - * visibility and nothing else — the ledger lives in shares/rate_history. */ + * + * ⚠️ This table is NOT display-only, whatever it once was. On a backend + * that serves no getblockhash — which is every enforcer, and therefore + * the production configuration — store_reconcile_blocks_from_templates() + * confirms a block by finding the template at height+1 whose prev_hash is + * that block. Trim that row and the block stops being confirmable: its + * confirmations freeze wherever they were, and under pplns a block frozen + * short of maturity is never distributed and its miners are never paid. + * + * The default retention is 30 days against a ~17-hour maturity, so there + * is a wide margin — but it is a margin, not an absence of coupling, and + * anyone tuning templates_retention_days down needs to know that. */ int keep_days = s->templates_retention_days; if (keep_days > 0) { static const char *Q_TRIM = "DELETE FROM templates WHERE ts < ?"; From 998b4a5410f594584ecae29d2ee96527d7fa5a65 Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 7 Sep 2026 12:46:57 +0200 Subject: [PATCH 12/18] Don't fail every payout because the wallet was never encrypted An operator who sets ENFORCER_WALLET_PASSPHRASE against a wallet that is not encrypted had every payout tick fail, and nobody was paid. A wallet that is not encrypted is already unlocked, and the enforcer says so with HTTP 409 already_exists / "enforcer wallet already unlocked". enforcerRpc turns any non-2xx into a throw, and ensureUnlocked() is awaited un-wrapped at the top of transferBatchDetailed, so that reply came straight out of the transfer. It is not a failure: the wallet can sign, which is the only thing the call is for. Not an exotic misconfiguration. --wallet-auto-create makes an UNENCRYPTED wallet, and that is how INSTALL.md and the regtest scripts create one, so this is the default wallet plus a passphrase set defensively -- or set for a wallet that was later decrypted. A wrong passphrase still throws. That one really does leave the wallet unable to sign, and swallowing it would turn a typo into payouts that stop with no reason given. Both directions are tested, and the tolerant one is mutation-checked: making ensureUnlocked rethrow unconditionally fails it. enforcerRpc now keeps `code` and `status` on the error it throws, so callers can tell one failure from another without matching on prose upstream is free to reword. ensureUnlocked still falls back to a message match as well. What this does NOT cover, now written where the next reader will find it: the LOCKED path. A regtest enforcer cannot be made to hold an encrypted wallet at all -- --wallet-auto-create creates an unencrypted wallet, and CreateWallet then refuses ("a wallet seed already exists") wallet on, uncreated the enforcer will not start: --enable-mempool is mandatory and its sync task refuses an uninitialized wallet --walletless WalletService/CreateWallet is not served at all -- so UnlockWallet's request shape cannot be verified here. An unencrypted wallet answers "already unlocked" before reading the body, so a probe with a deliberately bogus field name gets the same reply as a correct one: no regtest call can tell them apart. The field name matches CreateWallet's, which does take `password`, so it is probably right, and the comment says "probably" rather than letting it look covered. If it is wrong, the failure lands at the unlock rather than as a mispayment. payout 87, dashboard 138, both payout e2e suites still pass. --- payout/lib/enforcer-rpc.js | 9 ++++- payout/lib/enforcer-wallet.js | 51 ++++++++++++++++++++++++++++- payout/test/enforcer-wallet.test.js | 32 ++++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/payout/lib/enforcer-rpc.js b/payout/lib/enforcer-rpc.js index 85b07c2..23c272e 100644 --- a/payout/lib/enforcer-rpc.js +++ b/payout/lib/enforcer-rpc.js @@ -22,7 +22,14 @@ export async function enforcerRpc(enforcerAddr, rpcPath, body, timeoutMs = 30_00 } if (!r.ok) { const code = j.code || `http ${r.status}`; - throw new Error(`enforcer ${rpcPath}: ${code}${j.message ? `: ${j.message}` : ''}`); + const err = new Error( + `enforcer ${rpcPath}: ${code}${j.message ? `: ${j.message}` : ''}`); + /* Keep the machine-readable parts on the error. Callers that need + * to tell one failure from another should not have to match on + * prose that upstream is free to reword. */ + err.code = j.code ?? null; + err.status = r.status; + throw err; } return j; } finally { diff --git a/payout/lib/enforcer-wallet.js b/payout/lib/enforcer-wallet.js index 5bb57c5..00ac63d 100644 --- a/payout/lib/enforcer-wallet.js +++ b/payout/lib/enforcer-wallet.js @@ -66,9 +66,58 @@ export class EnforcerWalletClient { * unlocked, so do it once up front rather than discovering it mid-batch. * Unlocking is idempotent and cheap; a wallet with no passphrase * configured is assumed unencrypted and left alone. */ + /* ⚠️ UNVERIFIED AGAINST A REAL ENFORCER, and not for want of trying. + * + * Every other RPC this client makes is now exercised against a live node + * by tests/test_pplns_btc_payout_regtest.sh. This one cannot be, because + * a regtest enforcer cannot be made to hold an ENCRYPTED wallet: + * + * --wallet-auto-create creates an unencrypted wallet, and then + * CreateWallet refuses ("a wallet seed + * already exists") + * wallet on, not created the enforcer will not start: --enable-mempool + * is mandatory and its sync task refuses an + * uninitialized wallet + * --walletless WalletService/CreateWallet is not served at + * all (unimplemented) + * + * So the locked path has no coverage. What IS established: the method + * exists and is served at this path, and the enforcer carries + * AlreadyUnlocked / InvalidPassword / WalletNotUnlocked error variants. + * What is NOT: that `password` is the right field name. An unencrypted + * wallet answers "already unlocked" BEFORE reading the body, so a probe + * with a deliberately bogus field name gets the same reply as this one -- + * which means no regtest call can tell a correct request from a wrong one. + * + * The field name matches CreateWallet's, which does take `password`, so + * it is likely right. "Likely" is the honest word. The first operator to + * run an encrypted wallet is the test, and if this is wrong they will see + * payouts fail at the unlock rather than silently mispay -- which is the + * safe direction, but say so rather than let it look covered. */ async ensureUnlocked() { if (this._unlocked || !this.passphrase) return; - await this._call('UnlockWallet', { password: this.passphrase }); + try { + await this._call('UnlockWallet', { password: this.passphrase }); + } catch (e) { + /* A wallet that is not encrypted is already unlocked, and says so + * with already_exists / "enforcer wallet already unlocked". That + * is not a failure -- the wallet can sign, which is all this call + * is for -- but it was thrown straight out of + * transferBatchDetailed, so every tick failed and nobody was paid. + * + * Not an exotic misconfiguration: --wallet-auto-create makes an + * UNENCRYPTED wallet, and that is how the install guide and the + * regtest scripts create one. An operator who sets + * ENFORCER_WALLET_PASSPHRASE defensively, or who set it for a + * wallet that was later decrypted, lands here. + * + * A wrong passphrase still throws: that one really does leave the + * wallet unable to sign, and silence there would turn a typo into + * payouts that stop with no reason given. */ + const alreadyUnlocked = + e.code === 'already_exists' || /already unlocked/i.test(e.message || ''); + if (!alreadyUnlocked) throw e; + } this._unlocked = true; } diff --git a/payout/test/enforcer-wallet.test.js b/payout/test/enforcer-wallet.test.js index a4e0943..057f163 100644 --- a/payout/test/enforcer-wallet.test.js +++ b/payout/test/enforcer-wallet.test.js @@ -253,3 +253,35 @@ test('one SendTransaction carries every address in the batch', async () => { assert.deepEqual(sends[0].body.destinations, { addr_a: 250000, addr_b: 300000 }); assert.equal(res.txid, 'deadbeef'); }); + +/* Unlocking, and the one shape of it that a regtest enforcer can reach. + * + * The locked path itself is NOT covered here or anywhere: see the note on + * ensureUnlocked. These pin the reachable half. */ + +test('an unencrypted wallet reports "already unlocked", and that is not a failure', async () => { + /* --wallet-auto-create makes an UNENCRYPTED wallet -- the install guide's + * way and the regtest scripts' way -- and such a wallet answers + * UnlockWallet with HTTP 409 already_exists. That was thrown straight out + * of transferBatchDetailed, so an operator who set + * ENFORCER_WALLET_PASSPHRASE against it had every payout tick fail. The + * wallet can sign, which is the only thing the call is for. */ + const c = new EnforcerWalletClient({ addr: 'x', passphrase: 'hunter2' }); + const err = new Error('enforcer .../UnlockWallet: already_exists: enforcer wallet already unlocked'); + err.code = 'already_exists'; + stub(c, { UnlockWallet: err, SendTransaction: { txid: { hex: 'aa' } } }); + const res = await c.transferBatchDetailed([{ address: 'a', sats: 1n }]); + assert.equal(res.txid, 'aa', 'the payout still goes out'); +}); + +test('a wrong passphrase still fails loudly', async () => { + /* The opposite case, and it must stay noisy: a wallet that cannot be + * unlocked cannot sign, and swallowing that turns a typo into payouts + * that stop with no reason given. */ + const c = new EnforcerWalletClient({ addr: 'x', passphrase: 'wrong' }); + const err = new Error('enforcer .../UnlockWallet: invalid_argument: invalid password'); + err.code = 'invalid_argument'; + stub(c, { UnlockWallet: err, SendTransaction: { txid: { hex: 'aa' } } }); + await assert.rejects(() => c.transferBatchDetailed([{ address: 'a', sats: 1n }]), + /invalid password/); +}); From 712767ed4e60c1ea4e9ab6e1312c7ee86dd72f65 Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 7 Sep 2026 12:58:07 +0200 Subject: [PATCH 13/18] Document the pplns modes, in the three docs that said there were two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pplns` appeared in exactly one file in the repo — payout/README.md — while README.md, docs/simplepool.html and INSTALL.md all still told the reader simplepool has two modes. INSTALL.md is the one that mattered most: it is where an operator chooses pool_mode, so a pplns pool could not be set up by following the documentation at all. The other five docs that mention pps-classic were left alone deliberately. None of them enumerates the modes; their mentions are mode-specific statements that are still true. README.md — a four-row table of the two things pool_mode actually decides (whether the coinbase pays the miner or the pool, and what a username is), then the pplns entry: nothing credited on arrival, a block split across its window at 100 confirmations, fees included, window as a multiple of network difficulty snapshotted at find time. The framing throughout is who carries the variance, because that is the whole reason the mode exists — PPS needs a reserve measured in block rewards and ruins an operator who cannot fund it; pplns never owes more than it has just been paid. docs/simplepool.html — two more mode cards, a third palette colour for them, the comparison table extended to four columns with the rows that actually separate the modes (when a balance moves, whether work that found nothing is paid, whether fees are shared, whether a reserve is needed), and a note on why a fourth mode exists at all. INSTALL.md — the mode list, a PPLNS config section, and the three things pplns-btc needs that no other mode does: an enforcer with --enable-wallet, pool_btc_address from that wallet, PAYOUT_RAIL=btc on the worker. Part F is no longer "PPS modes only" and now carries the rail table; Part C says Thunder is not needed for pplns-btc. Two corrections found while writing: - INSTALL.md's pps-classic example set `pps_sats_per_diff = 1000`, which README.md tells you to leave unset and for good reason: a pinned rate silently bypasses fee_bps and cannot follow a retarget. It is an escape hatch, not a field to fill in. Removed, with the reason stated. - the removed drivechain-coinbase mode was described as "a third mode" in two places. It is now the fifth. Every config error quoted in the troubleshooting section was checked against the binary rather than transcribed from the source, and matches verbatim. --- INSTALL.md | 99 +++++++++++++++++++++++++++---- README.md | 120 ++++++++++++++++++++++++++++++------- docs/simplepool.html | 137 ++++++++++++++++++++++++++++++++++++------- 3 files changed, 301 insertions(+), 55 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index e3a76a6..b550f80 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -11,17 +11,29 @@ tracks: Thunder node. There's a one-shot deploy script; this doc also walks through what it does step by step so you can do it by hand. -The doc is mode-agnostic where possible; where mode matters, the two +The doc is mode-agnostic where possible; where mode matters, the four possibilities are called out clearly: - **`pool_mode = solo`** — miners paid direct in the coinbase. - Simplest. No drivechain, no Thunder, no PPS accrual. + Simplest. No drivechain, no Thunder, no accrual. - **`pool_mode = pps-classic`** — traditional coinbase paying a pool BTC address, operator-driven Thunder deposits from the admin dashboard. This is the mode you want for a Thunder-paying PPS pool. + Needs an operator reserve big enough to absorb variance. See [CLASSIC_PAYOUTS.md](CLASSIC_PAYOUTS.md). - -(A third mode, `pool_mode = pps`, put the drivechain deposit directly in +- **`pool_mode = pplns-thunder`** — pooled like PPS, but a block is + divided among the shares that produced it once it matures, so there + is no reserve to fund. Paid over Thunder; usernames are Thunder + addresses, and the payout worker is the same one. +- **`pool_mode = pplns-btc`** — the same accounting, paid on Bitcoin L1 + through the enforcer's own wallet. Usernames are Bitcoin addresses. + No Thunder node anywhere in the stack. + +If you cannot fund a PPS reserve, one of the `pplns-*` modes is the +pooled mode you can actually run: the pool never owes more than it has +just been paid. + +(A fifth mode, `pool_mode = pps`, put the drivechain deposit directly in the coinbase. The enforcer never credited it, so it has been removed — `CLASSIC_PAYOUTS.md` has the evidence.) @@ -393,7 +405,10 @@ simplepool talks to. Port `:50051` is the gRPC surface for sidechain management (used by the deposit runbook in [OPERATOR_GUIDE.md](OPERATOR_GUIDE.md)). -### Thunder (needed for `pool_mode=pps-classic` payouts) +### Thunder (needed for `pool_mode=pps-classic` and `pplns-thunder` payouts) + +Not needed for `pplns-btc`, which pays on the mainchain and has no +sidechain in it at all. Prebuilt: (no x86_64 Linux prebuilt as of this doc — build from source at @@ -476,12 +491,47 @@ Miner username: `[.]`. Password ignored. pool_mode = pps-classic pool_btc_address = bc1q... # pool wallet; ideally an enforcer-owned # address (see OPERATOR_GUIDE.md open items) -pps_sats_per_diff = 1000 ``` Miner username: `[.]`. Startup logs `pool_mode=pps-classic: pool_btc_address=…`. +Do **not** set `pps_sats_per_diff`. The proxy derives the rate from each +block template — the block's own value over the network difficulty, net +of `fee_bps` — so it tracks the chain. A pinned value silently bypasses +`fee_bps` and cannot follow a retarget; it exists as an escape hatch, not +as a setting to fill in. + +### PPLNS modes + +``` +# ... same as solo, plus: +pool_mode = pplns-thunder # or: pplns-btc +pool_btc_address = bc1q... # the coinbase pays the pool, as in pps-classic +pplns_window_diff_multiple = 2.0 # optional; this is the default +``` + +`pool_mode = pplns` on its own is refused — it does not say which rail +pays, and the rail decides what a stratum username is: + +| mode | miner username | +| --- | --- | +| `pplns-thunder` | `[.]` | +| `pplns-btc` | `[.]` | + +Nothing is credited when a share arrives. A block that reaches **100 +confirmations** is split across the shares that produced it, pro rata by +difficulty, over a window of `pplns_window_diff_multiple` × the current +network difficulty. The credits land in the same `pps_credits` table the +PPS payout worker already drains. + +`pplns-btc` additionally needs `bip300301_enforcer` running with +`--enable-wallet`, `pool_btc_address` set to an address **from that +wallet**, and the payout worker started with `PAYOUT_RAIL=btc` (Part F). +The proxy logs all three at startup, because otherwise the first sign of +a misconfiguration is a payout failing 100 blocks after the block was +found. + ### Optional: Redis broadcast Add to any mode's `proxy.conf`: @@ -559,10 +609,19 @@ on every request. --- -## Part F — payout worker (PPS modes only) +## Part F — payout worker (every mode except solo) + +The payout worker drains `pps_credits.accrued_sats - paid_sats`. One +worker, two rails, selected by `PAYOUT_RAIL`: + +| `pool_mode` | `PAYOUT_RAIL` | how it pays | +| --- | --- | --- | +| `pps-classic` | `thunder` (default) | Thunder transactions from the pool reserve | +| `pplns-thunder` | `thunder` (default) | the same | +| `pplns-btc` | `btc` | Bitcoin L1, via `WalletService/SendTransaction` on the enforcer | -The payout worker drains `pps_credits.accrued_sats - paid_sats` by -issuing Thunder transactions. Deploy as a systemd service: +The rail must match `pool_mode`: it is the same choice, and getting it +wrong means the worker cannot pay anyone. Deploy as a systemd service: ```sh # assumes deploy/systemd/simplepool-payout.service was already installed @@ -571,6 +630,16 @@ sudo mkdir -p /etc/systemd/system/simplepool-payout.service.d sudo tee /etc/systemd/system/simplepool-payout.service.d/local.conf <<'CONF' [Service] Environment=THUNDER_FROM_ADDRESS= +# +# For pool_mode=pplns-btc, drop the Thunder line above and use these two +# instead — the Thunder variables are then never read: +# Environment=PAYOUT_RAIL=btc +# Environment=ENFORCER_RPC_ADDR=127.0.0.1:50051 +# Environment=PAYOUT_FEE_RATE_SAT_VB=5 +# The enforcer computes the fee from the transaction it actually builds, +# so this is a rate, not an amount, and there is no local estimator to +# drift out of date. +# # Below have defaults; override if you want: # Environment=PAYOUT_MIN_SATS=10000 # Payout runs are a daily batch (24h). The settle clock is separate on @@ -586,7 +655,8 @@ sudo systemctl enable --now simplepool-payout.service sudo journalctl -u simplepool-payout.service -f ``` -The worker is idle when the Thunder reserve has no funds — it logs +The worker is idle when the paying wallet has no funds — the Thunder +reserve, or the enforcer wallet on `PAYOUT_RAIL=btc`. It logs `payout: reserve short — available=0 needed=N` and skips harmlessly, retrying on the 5-minute retry clock rather than the daily one. See the deposit runbook in [OPERATOR_GUIDE.md](OPERATOR_GUIDE.md) for how to actually fund it. @@ -616,7 +686,7 @@ Start everything: ```sh sudo systemctl enable --now simplepool.service sudo systemctl enable --now simplepool-dashboard.service -sudo systemctl enable --now simplepool-payout.service # PPS modes only +sudo systemctl enable --now simplepool-payout.service # every mode but solo sudo systemctl status simplepool simplepool-dashboard simplepool-payout ``` @@ -686,6 +756,13 @@ Install-time trouble usually falls into one of these: was ready. If you're using systemd, add `After=bip300301-enforcer.service` and `Requires=` to your Thunder unit; if running by hand, sleep 2s. +- **`config error: 'pool_mode = pplns' does not say which rail pays`** — + use `pplns-thunder` or `pplns-btc`. A pool runs one or the other, and + the rail decides what a stratum username is. +- **`config error: 'pplns_window_diff_multiple' must be > 0`** — it is a + multiple of the network difficulty; 2.0 is the default. Below 1.0 the + proxy warns rather than refuses: a block would then pay out across less + work than it took to find, which rewards pool hopping. - **`config error: 'pool_btc_address' is required when pool_mode=pps-classic`** — self-explanatory; set it. - **The pool logs `stratum listening on 0.0.0.0:3335` but miners are diff --git a/README.md b/README.md index 65f30f0..22fe786 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,14 @@ connections on TCP `:3334`, builds block templates via `bitcoind`'s accepted share into a local SQLite database. A separate Node.js dashboard reads that file for stats. -It runs in two modes: **solo**, where the miner who finds a block is paid in -that block's own coinbase, and **pps-classic**, where every accepted share -earns a derivable amount paid out over Thunder. Both ship in this repo — see -[The two modes](#the-two-modes) below. +It runs in four modes, which differ in who carries the variance: **solo**, +where the miner who finds a block is paid in that block's own coinbase; +**pps-classic**, where every accepted share earns a derivable amount and the +operator absorbs the variance out of a reserve; and **pplns-thunder** / +**pplns-btc**, where a matured block is split across the shares that produced +it, so the miners carry the variance and the pool never owes more than it has +just been paid. All four ship in this repo — see [The four +modes](#the-four-modes) below. Created by **Roberto Santacroce**. Canonical repository: . @@ -37,15 +41,23 @@ curl -fsSL https://raw.githubusercontent.com/LayerTwo-Labs/simplepool/main/scrip > verify what they're owed. simplepool aims to address this transparency > gap. (Hopefully!) -> A single-file, no-JavaScript explainer covering both modes end to end — +> A single-file, no-JavaScript explainer covering every mode end to end — > shares, difficulty, the coinbase, PPS credit, Thunder payouts and how to > audit every number — lives at [`docs/simplepool.html`](docs/simplepool.html). > Open it from disk or serve it next to the dashboard. -### The two modes +### The four modes -This repository ships **both modes**, selected by `pool_mode` in -`proxy.conf`: +This repository ships **all four**, selected by `pool_mode` in +`proxy.conf`. They differ in two independent things — whether the coinbase +pays the miner or the pool, and what a stratum username is: + +| `pool_mode` | coinbase pays | username | who carries the variance | +| --- | --- | --- | --- | +| `solo` | the miner who found it | Bitcoin address | nobody: you are paid what you find | +| `pps-classic` | the pool | Thunder address | the operator, out of a reserve | +| `pplns-thunder` | the pool | Thunder address | the miners | +| `pplns-btc` | the pool | Bitcoin address | the miners | - **`pool_mode = solo`** (default) — every share lands in the local SQLite store, every accepted block is paid directly in its own @@ -81,9 +93,65 @@ This repository ships **both modes**, selected by `pool_mode` in > see [`CLASSIC_PAYOUTS.md`](CLASSIC_PAYOUTS.md) for the evidence and > the design that replaced it. -In both modes the operator fee stays in BTC, paid to `operator_address` -out of the same coinbase. See [`proxy.conf.example`](proxy.conf.example) -for the full set of PPS / Thunder keys. +- **`pool_mode = pplns-thunder`** and **`pool_mode = pplns-btc`** — the + coinbase pays the pool, exactly as in `pps-classic`, but **nothing is + credited when a share arrives**. Instead, once a block has matured **100 + confirmations** it is split across the shares that produced it — walking + back from the block's own share until their difficulty fills a window — + and each miner is credited its proportion of `(reward + fees)`, net of + `fee_bps`. + + That is the whole difference, and it is a difference about risk. PPS + prices a share the moment it arrives, whether or not it ever becomes a + block, so the operator needs a reserve measured in block rewards to + absorb the gap. Under PPLNS the pool never owes more than it has just + been paid: there is no reserve to size and operator ruin is not a failure + mode. The miners carry the variance instead, which is what makes it the + mode a small pool can actually run. + + Two consequences worth stating, because each has a plausible-looking + wrong answer: + + - **Maturity, not confirmation.** A coinbase output is unspendable until + it is 100 deep, so crediting at confirmation would create a balance the + pool genuinely cannot fund — the reserve requirement PPLNS exists to + remove, reintroduced by accident. Waiting also disposes of the orphan + question rather than answering it: crediting is additive and there is + no negative share, so a credit from a block that turns out not to be + ours could not be taken back. At 100 deep that stops being a risk. + - **Transaction fees are included**, unlike pure PPS: PPLNS shares what + the block actually earned. + + The window is `pplns_window_diff_multiple` × the network difficulty + (default 2.0, "the last two blocks' worth of expected work"), a multiple + rather than an absolute share count so it self-scales across retargets. + It is snapshotted onto the block row when the block is found, not + recomputed when it is paid: those moments are ~100 blocks apart and the + chain can retarget in between. + + The two differ only in the rail the balance is finally paid over, and + that choice is what a stratum username has to be: + + - **`pplns-thunder`** pays over Thunder, like `pps-classic`, and reuses + the same payout worker draining the same `pps_credits` table. Username + is a bare base58 Thunder address. + - **`pplns-btc`** pays on Bitcoin L1, by asking the enforcer's own wallet + to send. Username is a Bitcoin address. This requires + `bip300301_enforcer` running with `--enable-wallet`, with + `pool_btc_address` an address from that wallet, and the payout worker + started with `PAYOUT_RAIL=btc` — the proxy says so at startup, because + otherwise the first sign of a misconfiguration is a payout failing 100 + blocks after the block was found. + + One rail per pool, encoded in `pool_mode` rather than a mode plus a + separate rail knob, so the inconsistent configuration is unrepresentable + rather than merely rejected. + +In every mode the operator fee stays in BTC, paid to `operator_address` +out of the same coinbase. On PPLNS it is normally set lower than on PPS: +there is no variance being absorbed, so there is no risk premium to charge +for. See [`proxy.conf.example`](proxy.conf.example) for the full set of +PPS / PPLNS / Thunder keys. Optional: set `redis_url` to mirror accepted shares, rejects, blocks, tip changes and PPS credits to Redis pub/sub channels (`pool:shares`, @@ -109,6 +177,11 @@ and historical "blocks found by the pool" view. accepted share credits a balance at a rate derived from the live block template, and the pool — not the miner — carries the variance. +**In the `pplns-*` modes** the coinbase also pays the pool, but no balance +moves until a block matures; it is then divided among the shares that +produced it. Nobody is paid for work that did not become a block, which is +precisely why the pool needs no reserve. + ### A note on terminology: "share" vs "work" The codebase, schema, dashboard, and API all call accepted submissions @@ -550,7 +623,7 @@ src/ store.{c,h} # SQLite writer with batching bitcoind.{c,h} # libcurl-based JSON-RPC client broadcast.{c,h} # optional Redis pub/sub mirror of pool events - thunder.{c,h} # Thunder base58 address decoder (pps-classic) + thunder.{c,h} # Thunder base58 address decoder (pps-classic, pplns-thunder) version.{c,h} # build provenance compiled into the binary cjson/ # vendored cJSON (MIT) — see src/cjson/README.md tests/ # unit tests + integration shell scripts @@ -561,8 +634,9 @@ scripts/ release.sh # build a release tarball (CI runs this exact script) deploy-to-server.sh, sync-from-server.sh, record-build.sh, ... dashboard/ # Node/Express read-only stats UI -payout/ # Thunder payout worker (pps-classic) -docs/simplepool.html # single-file explainer: both modes, end to end +payout/ # payout worker: Thunder rail (pps-classic, pplns-thunder) + # and L1 rail via the enforcer wallet (pplns-btc) +docs/simplepool.html # single-file explainer: every mode, end to end ``` ## Roadmap @@ -576,14 +650,16 @@ Shipped since this list was first written: - **Redis broadcast.** Accepted shares, rejects, blocks, tip changes and PPS credits are mirrored onto Redis pub/sub when `redis_url` is set. SQLite remains the source of truth; the publish is fire-and-forget. -- **PPS billing as a separate, non-blocking service.** `pool_mode = - pps-classic` accrues credits in the proxy; the separate - [`payout/`](payout/) worker settles them over **Thunder** on its own - process and its own schedule. A payout outage cannot stop the proxy - accepting work. -- **Miner registration turned out to be unnecessary.** PPS miners are - identified by the Thunder address in the stratum username, exactly as solo - miners are identified by their BTC address. There is nothing to register. +- **Billing as a separate, non-blocking service.** Both the PPS and the + PPLNS modes accrue credits in the proxy, into the same `pps_credits` + table; the separate [`payout/`](payout/) worker settles them on its own + process and its own schedule — over **Thunder** for `pps-classic` and + `pplns-thunder`, and on **L1** through the enforcer's wallet for + `pplns-btc`. A payout outage cannot stop the proxy accepting work. +- **Miner registration turned out to be unnecessary.** Miners are identified + by the address in the stratum username — Thunder or Bitcoin depending on + the mode's rail — exactly as solo miners are identified by their BTC + address. There is nothing to register. Still open: diff --git a/docs/simplepool.html b/docs/simplepool.html index 398603e..8616fd5 100644 --- a/docs/simplepool.html +++ b/docs/simplepool.html @@ -4,7 +4,7 @@ simplepool — how it works - +