From a36a7a20855b61cd77c17d4277564d8018234f23 Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 7 Sep 2026 20:14:17 +0200 Subject: [PATCH 01/36] Coinbase-direct PPLNS: the N-output builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First piece of the third rail proposed on #61 — pay the PPLNS window straight out of the coinbase of the block that produced it, so the pool never receives the reward at all. No wallet, no payout worker, no write-ahead row, no credit-on-confirmation. The premise holds. CLASSIC_PAYOUTS.md rules out coinbase payment because the enforcer will not credit a coinbase output as a drivechain deposit, but that is a constraint about paying INTO a sidechain. It says nothing about L1 destinations, and solo mode already pays miners straight from the coinbase. It also removes the maturity gate rather than merely the wallet. That gate exists because crediting is additive with no negative share, so a credit from a block that turns out not to be ours cannot be clawed back. Here there is nothing to claw back: a reorged block simply never paid. This commit is the primitive only — the builder plus its tests. Nothing calls it yet; the window is still computed at maturity, and wiring it to job-build time is the next step. The design question the proposal raises and does not settle is where dropped value goes, so that is what the code answers: A coinbase that pays out less than it is allowed does not leave the remainder anywhere. It destroys it. So a payee below the dust limit, or past the output cap, cannot just be skipped. Its value rides on the operator output and is reported as carry_sats, separately from fee_sats, so a ledger can tell the operator's income from its liability. That is the honest form of the cost the comment names: this is not zero custody, it is custody proportional to dust, and the number is in the result struct rather than implied. Where there is no operator address to carry to, the build is refused rather than burning it. Other decisions worth stating: - payees are paid largest first, so the cap and the dust limit fall on the smallest claims: the ones for whom waiting a block costs least and whose carried balance is smallest. - the caller's split must sum to exactly (value - fee). A shortfall would be forfeited to nobody, so it is refused as a caller bug rather than papered over. - a window where nobody clears dust is refused. Paying the operator the entire block and calling it a fee is the worst available outcome. - the output cap defaults to 200 and is not a consensus limit. Consensus bounds the coinbase by weight, and even a thousand P2WPKH outputs is a few percent of the budget; the real constraint is that some marketplaces verify a coinbase and reject one they consider oversized. - ties break by input order, so the same window builds the same coinbase twice. A miner checking the block it was paid from has to get the same answer the pool did. Nine tests, and the ones that matter read the assembled transaction rather than the builder's own report: outputs are counted and summed out of cb2, and every case asserts the whole block leaves in outputs. Mutation-verified — making the carry vanish instead of riding the operator output fails the dust test. Still to do: the window at template time, the mode itself, and the per-connection coinbase question — a shared N-output coinbase is identical for every connection and much larger, which touches the extranonce layout and the job rebuild path in stratum.c, not just this file. --- src/coinbase.c | 247 ++++++++++++++++++++++++++++++++++++++++++ src/coinbase.h | 66 +++++++++++ tests/test_coinbase.c | 224 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 537 insertions(+) diff --git a/src/coinbase.c b/src/coinbase.c index 8f86cd7..7365910 100644 --- a/src/coinbase.c +++ b/src/coinbase.c @@ -712,6 +712,253 @@ static int rd_u64(const uint8_t *buf, size_t len, size_t *off, uint64_t *val) { return 0; } +/* ---------- coinbase-direct PPLNS ---------- */ + +/* Sort helper: largest claim first, ties broken by original position so the + * output order is deterministic for a given window. A stable, reproducible + * coinbase matters -- a miner checking the block it was paid from should get + * the same answer twice. */ +typedef struct { size_t idx; int64_t sats; } payee_rank_t; + +static int payee_rank_cmp(const void *a, const void *b) { + const payee_rank_t *x = a, *y = b; + if (x->sats != y->sats) return x->sats > y->sats ? -1 : 1; + return x->idx < y->idx ? -1 : (x->idx > y->idx ? 1 : 0); +} + +int coinbase_build_window(uint32_t height, int64_t value_sats, + const coinbase_payee_t *payees, size_t n_payees, + const char *operator_address, int fee_bps, + const char *witness_commitment_hex, + const char *coinbase_tag, + size_t extranonce1_size, size_t extranonce2_size, + size_t max_payout_outputs, + coinbase_parts_t *out, + coinbase_window_result_t *res, + char *errbuf, size_t errlen) { + coinbase_window_result_t r = {0}; + if (res) *res = r; + if (!out || (!payees && n_payees > 0)) { + set_err(errbuf, errlen, "null arg"); + return -1; + } + out->cb1 = NULL; out->cb1_len = 0; + out->cb2 = NULL; out->cb2_len = 0; + if (n_payees == 0) { + set_err(errbuf, errlen, "window is empty: nobody to pay"); + return -1; + } + if (value_sats <= 0) { + set_err(errbuf, errlen, "value_sats must be positive"); + return -1; + } + if (max_payout_outputs == 0) max_payout_outputs = COINBASE_MAX_PAYOUT_OUTPUTS; + + /* The operator fee, on the same terms as every other builder: off the + * top, dropped entirely if it would be dust. */ + int64_t fee_sats = 0; + uint8_t operator_spk[64]; + size_t operator_spk_len = 0; + int has_operator = 0; + if (operator_address && operator_address[0] && fee_bps > 0) { + int64_t f = (value_sats * (int64_t)fee_bps) / 10000; + if (f >= COINBASE_DUST_SATS) { + if (coinbase_address_to_script(operator_address, operator_spk, + sizeof operator_spk, + &operator_spk_len, + errbuf, errlen) < 0) { + return -1; + } + fee_sats = f; + has_operator = 1; + } + } + + /* The caller's split must account for the whole payable amount. A + * shortfall here would be forfeited to nobody -- a coinbase paying out + * less than it may simply destroys the difference -- so it is a caller + * bug, not something to paper over. */ + int64_t payable = value_sats - fee_sats; + int64_t claimed = 0; + for (size_t i = 0; i < n_payees; ++i) { + if (!payees[i].address || !payees[i].address[0]) { + set_err(errbuf, errlen, "payee %zu has no address", i); + return -1; + } + if (payees[i].sats < 0) { + set_err(errbuf, errlen, "payee %zu has a negative amount", i); + return -1; + } + claimed += payees[i].sats; + } + if (claimed != payable) { + set_err(errbuf, errlen, + "payees sum to %lld but the block pays %lld after a %lld fee", + (long long)claimed, (long long)payable, (long long)fee_sats); + return -1; + } + + payee_rank_t *rank = calloc(n_payees, sizeof *rank); + if (!rank) { set_err(errbuf, errlen, "oom"); return -1; } + for (size_t i = 0; i < n_payees; ++i) { + rank[i].idx = i; + rank[i].sats = payees[i].sats; + } + qsort(rank, n_payees, sizeof *rank, payee_rank_cmp); + + /* Resolve and emit, largest first, until the cap or the dust limit stops + * us. Everything not paid becomes carry. */ + bbuf_t outs; + bbuf_init(&outs); + uint64_t n_outputs = 0; + int64_t carry = 0; + + for (size_t k = 0; k < n_payees; ++k) { + const coinbase_payee_t *pe = &payees[rank[k].idx]; + if (pe->sats < COINBASE_DUST_SATS) { + r.dropped_dust++; + carry += pe->sats; + continue; + } + if (r.paid_count >= max_payout_outputs) { + r.dropped_capped++; + carry += pe->sats; + continue; + } + uint8_t spk[64]; + size_t spk_len = 0; + if (coinbase_address_to_script(pe->address, spk, sizeof spk, + &spk_len, errbuf, errlen) < 0) { + bbuf_free(&outs); free(rank); + return -1; + } + if (bbuf_push_u64_le(&outs, (uint64_t)pe->sats) < 0) goto oom; + if (bbuf_push_varint(&outs, spk_len) < 0) goto oom; + if (bbuf_push(&outs, spk, spk_len) < 0) goto oom; + n_outputs++; + r.paid_count++; + r.paid_sats += pe->sats; + } + free(rank); + rank = NULL; + + /* Nobody cleared the dust limit. Refusing beats emitting a coinbase that + * pays the operator the entire block and calls it a fee. */ + if (r.paid_count == 0) { + bbuf_free(&outs); + set_err(errbuf, errlen, + "no payee in the window clears the %d-sat dust limit", + COINBASE_DUST_SATS); + return -1; + } + + /* The carry rides on the operator output, because it has to ride + * somewhere: value not paid out is value destroyed. The operator now + * holds it and owes it -- see coinbase_window_result_t. */ + int64_t operator_out = fee_sats + carry; + if (operator_out > 0 && !has_operator) { + if (!operator_address || !operator_address[0]) { + bbuf_free(&outs); + set_err(errbuf, errlen, + "%lld sats could not be paid to the window and there is no " + "operator_address to carry them", (long long)operator_out); + return -1; + } + if (coinbase_address_to_script(operator_address, operator_spk, + sizeof operator_spk, &operator_spk_len, + errbuf, errlen) < 0) { + bbuf_free(&outs); + return -1; + } + has_operator = 1; + } + if (has_operator && operator_out > 0) { + if (bbuf_push_u64_le(&outs, (uint64_t)operator_out) < 0) goto oom; + if (bbuf_push_varint(&outs, operator_spk_len) < 0) goto oom; + if (bbuf_push(&outs, operator_spk, operator_spk_len) < 0) goto oom; + n_outputs++; + } + + /* Witness commitment, byte-for-byte, last. */ + uint8_t wc_buf[256]; + size_t wc_len = 0; + if (witness_commitment_hex && *witness_commitment_hex) { + if (hex_decode(witness_commitment_hex, wc_buf, sizeof wc_buf, &wc_len) < 0) { + bbuf_free(&outs); + set_err(errbuf, errlen, "bad witness commitment hex"); + return -1; + } + if (bbuf_push_u64_le(&outs, 0) < 0) goto oom; + if (bbuf_push_varint(&outs, wc_len) < 0) goto oom; + if (bbuf_push(&outs, wc_buf, wc_len) < 0) goto oom; + n_outputs++; + } + + /* Every satoshi is accounted for, or the block burns the difference. */ + if (r.paid_sats + operator_out != value_sats) { + bbuf_free(&outs); + set_err(errbuf, errlen, + "internal: outputs sum to %lld, block pays %lld", + (long long)(r.paid_sats + operator_out), (long long)value_sats); + return -1; + } + + /* scriptSig, exactly as coinbase_build_split lays it out. */ + uint8_t height_push[8]; + size_t height_push_len = bip34_height_push(height, height_push); + uint8_t tag_push[80]; + size_t tag_push_len = 0; + if (coinbase_tag && *coinbase_tag) { + size_t tlen = strlen(coinbase_tag); + if (tlen > 75) tlen = 75; + tag_push[0] = (uint8_t)tlen; + memcpy(tag_push + 1, coinbase_tag, tlen); + tag_push_len = tlen + 1; + } + size_t en_total = extranonce1_size + extranonce2_size; + size_t script_sig_len = height_push_len + tag_push_len + en_total; + if (script_sig_len < 2 || script_sig_len > 100) { + bbuf_free(&outs); + set_err(errbuf, errlen, "coinbase scriptSig length %zu out of range " + "(height %zu + tag %zu + extranonce %zu)", + script_sig_len, height_push_len, tag_push_len, en_total); + return -1; + } + + bbuf_t c1, c2; + bbuf_init(&c1); + bbuf_init(&c2); + /* version, input count, prevout (null), scriptSig len, height, tag */ + if (bbuf_push_u32_le(&c1, 2) < 0) goto oom2; + if (bbuf_push_varint(&c1, 1) < 0) goto oom2; + for (int i = 0; i < 32; ++i) if (bbuf_push_u8(&c1, 0) < 0) goto oom2; + if (bbuf_push_u32_le(&c1, 0xffffffffu) < 0) goto oom2; + if (bbuf_push_varint(&c1, script_sig_len) < 0) goto oom2; + if (bbuf_push(&c1, height_push, height_push_len) < 0) goto oom2; + if (tag_push_len && bbuf_push(&c1, tag_push, tag_push_len) < 0) goto oom2; + /* cb2: sequence, outputs, locktime */ + if (bbuf_push_u32_le(&c2, 0xffffffffu) < 0) goto oom2; + if (bbuf_push_varint(&c2, n_outputs) < 0) goto oom2; + if (bbuf_push(&c2, outs.data, outs.len) < 0) goto oom2; + if (bbuf_push_u32_le(&c2, 0) < 0) goto oom2; + + bbuf_free(&outs); + out->cb1 = c1.data; out->cb1_len = c1.len; + out->cb2 = c2.data; out->cb2_len = c2.len; + r.fee_sats = fee_sats; + r.carry_sats = carry; + if (res) *res = r; + return 0; + +oom2: + bbuf_free(&c1); bbuf_free(&c2); +oom: + bbuf_free(&outs); + free(rank); + set_err(errbuf, errlen, "oom"); + return -1; +} + int coinbase_build_from_template(const char *coinbase_tx_hex, const char *miner_address, const char *operator_address, diff --git a/src/coinbase.h b/src/coinbase.h index 65c957f..f3857df 100644 --- a/src/coinbase.h +++ b/src/coinbase.h @@ -48,6 +48,72 @@ int coinbase_build_split(uint32_t height, int64_t value_sats, int64_t *out_miner_sats, int64_t *out_fee_sats, char *errbuf, size_t errlen); +/* ---- coinbase-direct PPLNS --------------------------------------------- + * + * One miner's claim on this block's coinbase. */ +typedef struct { + const char *address; /* L1 destination, as authorized on stratum */ + int64_t sats; /* what the window entitles this miner to */ +} coinbase_payee_t; + +/* What the builder actually managed to pay, and what it could not. + * + * `carry_sats` is the honest part. A payee below the dust limit, or past the + * output cap, cannot be paid in THIS coinbase — but its value cannot simply + * vanish either: a coinbase that pays out less than it is allowed forfeits + * the difference to nobody. So the shortfall is added to the operator output + * and reported here, which means the pool is holding it and owes it. + * + * That is the cost the design has to own: coinbase-direct removes custody for + * everyone the block can pay, and replaces it with a small, bounded, + * disclosable balance for everyone it cannot. It is not "zero custody"; it is + * custody proportional to dust, and the number is right here rather than + * implied. */ +typedef struct { + size_t paid_count; /* payees given an output */ + int64_t paid_sats; /* summed across those outputs */ + size_t dropped_dust; /* payees below COINBASE_DUST_SATS */ + size_t dropped_capped; /* payees past max_payout_outputs */ + int64_t carry_sats; /* owed to the dropped, paid to the operator */ + int64_t fee_sats; /* the operator's actual fee, excluding carry */ +} coinbase_window_result_t; + +/* A practical ceiling on payout outputs, not a consensus one. + * + * Consensus bounds the coinbase by block weight; at ~31 bytes per P2WPKH + * output even a thousand payees is a low single-digit percentage of the + * budget. The real constraint is that some rented-hashrate marketplaces + * verify a coinbase and reject one they consider oversized, and a delisting + * costs more than paying a few small miners a block later. */ +#define COINBASE_MAX_PAYOUT_OUTPUTS 200 + +/* Build cb1/cb2 paying the PPLNS window DIRECTLY, one output per miner. + * + * The point of the mode: the pool never receives the reward, so there is no + * wallet, no payout worker, no write-ahead row and no credit-on-confirmation. + * A reorged block simply never paid, which is also why this rail needs no + * maturity gate — there is no credit to claw back. + * + * `payees` must sum to exactly (value_sats - fee), where fee is the same + * fee_bps split every other builder applies. A caller whose arithmetic does + * not add up is refused rather than silently underpaying the block. + * + * Payees are paid largest first, so the cap and the dust limit fall on the + * smallest claims — the ones for whom waiting a block costs least, and whose + * carried balance is smallest. + * + * Returns 0 ok, negative on error (errbuf populated). `res` may be NULL. */ +int coinbase_build_window(uint32_t height, int64_t value_sats, + const coinbase_payee_t *payees, size_t n_payees, + const char *operator_address, int fee_bps, + const char *witness_commitment_hex, + const char *coinbase_tag, + size_t extranonce1_size, size_t extranonce2_size, + size_t max_payout_outputs, + coinbase_parts_t *out, + coinbase_window_result_t *res, + char *errbuf, size_t errlen); + /* Build coinbase1/coinbase2 halves from a server-provided coinbase * transaction (BIP22 "coinbasetxn", e.g. from the CUSF enforcer), rather * than constructing the coinbase from scratch. diff --git a/tests/test_coinbase.c b/tests/test_coinbase.c index 45c05df..10418cf 100644 --- a/tests/test_coinbase.c +++ b/tests/test_coinbase.c @@ -789,8 +789,232 @@ static void test_payout_txout_weight_matches_p2tr(void) { printf("ok: a P2TR payout output is %zu bytes / %zu WU\n", spk_len, wu); } +/* ---- coinbase-direct PPLNS --------------------------------------------- + * + * The rail where the pool never receives the reward: the window is paid + * straight out of the coinbase of the block it produced. No wallet, no payout + * worker, no maturity gate — a reorged block simply never paid. + * + * The property these are really defending is conservation. A coinbase that + * pays out less than it is allowed does not leave the remainder anywhere; it + * destroys it. So every satoshi of the block has to leave in an output, and + * anything the window cannot be paid has to be visibly carried rather than + * quietly dropped. */ + +#define WA "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080" +#define WB "bcrt1qzyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3lgth6c" +#define WC "bcrt1qyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zs4w3j0" +#define WOP "bcrt1qxvenxvenxvenxvenxvenxvenxvenxvenztev8a" + +/* Count outputs and sum their values out of the assembled coinbase, so the + * assertions read the transaction rather than the builder's own report. */ +static void window_outputs(const coinbase_parts_t *p, size_t en_total, + uint64_t *n_out, int64_t *sum_out) { + /* cb2 = sequence(4) | varint n_outputs | outputs | locktime(4) */ + const uint8_t *b = p->cb2; + size_t off = 4; + uint64_t n = b[off++]; /* every case here is < 253 outputs */ + int64_t sum = 0; + for (uint64_t i = 0; i < n; ++i) { + int64_t v = 0; + for (int k = 0; k < 8; ++k) v |= ((int64_t)b[off + k]) << (8 * k); + off += 8; + size_t spk_len = b[off++]; + off += spk_len; + sum += v; + } + (void)en_total; + *n_out = n; + *sum_out = sum; +} + +static void test_window_pays_each_miner_its_own_output(void) { + coinbase_parts_t parts; char err[256]; + coinbase_window_result_t res; + /* 1% of 5,000,000,000 is 50,000,000, leaving 4,950,000,000 to split. */ + const coinbase_payee_t payees[] = { + { WA, 2475000000LL }, { WB, 1485000000LL }, { WC, 990000000LL }, + }; + int rc = coinbase_build_window(800000, 5000000000LL, payees, 3, + WOP, 100, NULL, "/simplepool/", 4, 8, + 0, &parts, &res, err, sizeof err); + assert(rc == 0); + assert(res.paid_count == 3); + assert(res.fee_sats == 50000000LL); + assert(res.carry_sats == 0); + assert(res.paid_sats == 4950000000LL); + + uint64_t n = 0; int64_t sum = 0; + window_outputs(&parts, 12, &n, &sum); + assert(n == 4); /* three miners + the operator */ + assert(sum == 5000000000LL); /* the whole block, nothing burnt */ + coinbase_parts_free(&parts); + printf("ok: window pays each miner its own coinbase output\n"); +} + +/* A split that does not add up is a caller bug, and the honest response is to + * refuse: emitting it would silently forfeit the difference to nobody. */ +static void test_a_split_that_does_not_add_up_is_refused(void) { + coinbase_parts_t parts; char err[256]; + const coinbase_payee_t short_[] = { { WA, 1000000LL } }; + int rc = coinbase_build_window(800000, 5000000000LL, short_, 1, + WOP, 100, NULL, NULL, 4, 8, + 0, &parts, NULL, err, sizeof err); + assert(rc < 0); + assert(strstr(err, "payees sum to") != NULL); + + const coinbase_payee_t over[] = { { WA, 9000000000LL } }; + rc = coinbase_build_window(800000, 5000000000LL, over, 1, + WOP, 100, NULL, NULL, 4, 8, + 0, &parts, NULL, err, sizeof err); + assert(rc < 0); + printf("ok: a window split that does not sum to the block is refused\n"); +} + +/* Dust. A miner too small to pay cannot simply be dropped -- its value has to + * go somewhere, and the only honest somewhere is the operator, who then owes + * it. This is the custodial balance the design has to admit to. */ +static void test_a_dust_payee_is_carried_not_burnt(void) { + coinbase_parts_t parts; char err[256]; + coinbase_window_result_t res; + /* fee 1% of 100,000,000 = 1,000,000; payable 99,000,000. */ + const coinbase_payee_t payees[] = { + { WA, 98999900LL }, + { WB, 100LL }, /* far below the 546-sat dust limit */ + }; + int rc = coinbase_build_window(800000, 100000000LL, payees, 2, + WOP, 100, NULL, NULL, 4, 8, + 0, &parts, &res, err, sizeof err); + assert(rc == 0); + assert(res.paid_count == 1); + assert(res.dropped_dust == 1); + assert(res.carry_sats == 100LL); + /* The fee is reported separately from what is merely being held, so a + * ledger can tell the operator's income from its liability. */ + assert(res.fee_sats == 1000000LL); + + uint64_t n = 0; int64_t sum = 0; + window_outputs(&parts, 12, &n, &sum); + assert(n == 2); /* one miner + the operator */ + assert(sum == 100000000LL); /* still the whole block */ + coinbase_parts_free(&parts); + printf("ok: a dust payee is carried on the operator output, not burnt\n"); +} + +/* The output cap is about marketplaces rejecting an oversized coinbase, so it + * has to fall on the smallest claims: they are the ones for whom waiting a + * block costs least, and whose carried balance is smallest. */ +static void test_the_cap_falls_on_the_smallest_claims(void) { + coinbase_parts_t parts; char err[256]; + coinbase_window_result_t res; + const coinbase_payee_t payees[] = { + { WA, 1000000LL }, { WB, 3000000LL }, { WC, 6000000LL }, + }; + /* fee 1% of 10,101,010 ~ 101,010; make the numbers exact instead. */ + int64_t value = 1000000LL + 3000000LL + 6000000LL; /* fee_bps 0: no fee */ + /* The operator address is still required: capping produces carry, and + * carry needs somewhere to ride even when there is no fee. */ + int rc = coinbase_build_window(800000, value, payees, 3, + WOP, 0, NULL, NULL, 4, 8, + 2, &parts, &res, err, sizeof err); + assert(rc == 0); + assert(res.paid_count == 2); + assert(res.dropped_capped == 1); + /* The 1,000,000 claim is the one that waits, not the 6,000,000 one. */ + assert(res.carry_sats == 1000000LL); + assert(res.paid_sats == 9000000LL); + coinbase_parts_free(&parts); + printf("ok: the output cap drops the smallest claims first\n"); +} + +/* With no operator address there is nowhere to carry to, so a window that + * cannot be paid in full has to be refused rather than silently burn it. */ +static void test_carry_without_an_operator_address_is_refused(void) { + coinbase_parts_t parts; char err[256]; + const coinbase_payee_t payees[] = { + { WA, 999900LL }, { WB, 100LL }, + }; + int rc = coinbase_build_window(800000, 1000000LL, payees, 2, + NULL, 0, NULL, NULL, 4, 8, + 0, &parts, NULL, err, sizeof err); + assert(rc < 0); + assert(strstr(err, "no operator_address to carry") != NULL); + printf("ok: carry with nowhere to go is refused, not burnt\n"); +} + +/* If nobody clears dust, paying the operator the whole block and calling it a + * fee would be the worst possible outcome. */ +static void test_a_window_of_only_dust_is_refused(void) { + coinbase_parts_t parts; char err[256]; + const coinbase_payee_t payees[] = { { WA, 100LL }, { WB, 100LL } }; + int rc = coinbase_build_window(800000, 200LL, payees, 2, + WOP, 0, NULL, NULL, 4, 8, + 0, &parts, NULL, err, sizeof err); + assert(rc < 0); + assert(strstr(err, "dust limit") != NULL); + printf("ok: a window of nothing but dust is refused\n"); +} + +static void test_an_empty_window_is_refused(void) { + coinbase_parts_t parts; char err[256]; + int rc = coinbase_build_window(800000, 5000000000LL, NULL, 0, + WOP, 100, NULL, NULL, 4, 8, + 0, &parts, NULL, err, sizeof err); + assert(rc < 0); + assert(strstr(err, "nobody to pay") != NULL); + printf("ok: an empty window is refused\n"); +} + +/* The witness commitment has to survive byte-for-byte and stay last, exactly + * as the other builders keep it -- a block whose commitment moved or changed + * is invalid. */ +static void test_the_witness_commitment_is_preserved(void) { + coinbase_parts_t parts; char err[256]; + coinbase_window_result_t res; + const char *wc = "6a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf9"; + const coinbase_payee_t payees[] = { { WA, 5000000000LL } }; + int rc = coinbase_build_window(800000, 5000000000LL, payees, 1, + NULL, 0, wc, NULL, 4, 8, + 0, &parts, &res, err, sizeof err); + assert(rc == 0); + uint64_t n = 0; int64_t sum = 0; + window_outputs(&parts, 12, &n, &sum); + assert(n == 2); /* the miner, then the commitment */ + assert(sum == 5000000000LL); /* the commitment output carries 0 */ + coinbase_parts_free(&parts); + printf("ok: the witness commitment is preserved and stays last\n"); +} + +/* Same window, same bytes, twice. A miner checking the block it was paid from + * has to get the same answer as the pool did. */ +static void test_the_coinbase_is_deterministic(void) { + coinbase_parts_t a, b; char err[256]; + const coinbase_payee_t payees[] = { + { WA, 1000000LL }, { WB, 1000000LL }, { WC, 3000000LL }, + }; + int64_t value = 5000000LL; + assert(coinbase_build_window(800000, value, payees, 3, NULL, 0, NULL, + "/sp/", 4, 8, 0, &a, NULL, err, sizeof err) == 0); + assert(coinbase_build_window(800000, value, payees, 3, NULL, 0, NULL, + "/sp/", 4, 8, 0, &b, NULL, err, sizeof err) == 0); + assert(a.cb2_len == b.cb2_len); + assert(memcmp(a.cb2, b.cb2, a.cb2_len) == 0); + coinbase_parts_free(&a); + coinbase_parts_free(&b); + printf("ok: the same window builds the same coinbase twice\n"); +} + int main(void) { test_p2pkh_address(); + test_window_pays_each_miner_its_own_output(); + test_a_split_that_does_not_add_up_is_refused(); + test_a_dust_payee_is_carried_not_burnt(); + test_the_cap_falls_on_the_smallest_claims(); + test_carry_without_an_operator_address_is_refused(); + test_a_window_of_only_dust_is_refused(); + test_an_empty_window_is_refused(); + test_the_witness_commitment_is_preserved(); + test_the_coinbase_is_deterministic(); test_p2wpkh_address(); test_regtest_p2wpkh(); test_build_coinbase_structural(); From 413981f405f39c61ca88487c9476d324a4b3bed7 Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 7 Sep 2026 20:45:17 +0200 Subject: [PATCH 02/36] Coinbase-direct PPLNS: the window as it stands now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second piece. store_pplns_distribute() reads the window of a block that has already matured, anchored on that block's own share. A coinbase-direct pool needs the window a block found RIGHT NOW would pay, anchored on the newest share there is — roughly 100 blocks before the other one would run. Same walk, different anchor, and the boundary rule is copied deliberately rather than reinvented: `running - difficulty < window` compares against the total EXCLUDING the current row, which is what counts the share crossing the boundary whole instead of splitting it. If these two ever disagree, the pool pays out something other than what its template promised. Mutation-verified — changing the comparison to `running < window` fails the tests. Two decisions this raised that the distributor never had to make, because it credits a ledger where this pays an output: A worker with no payout address is left out of the split entirely, and out of the DENOMINATOR too. It cannot be given a coinbase output, and leaving it in the total would shrink everyone else's share to fund an output that is never created — value destroyed rather than merely unpaid. The distributor never meets this because pps_credits is keyed on worker_id and an address is only needed later, at payout. Truncation is reported rather than absorbed. Past `cap`, the tail is absent from both the entries and the total, so its claim is redistributed to the others rather than carried as a debt — the opposite of what the builder does with an output cap. The caller has to decide whether that is acceptable, so out_truncated says it happened rather than leaving it to be discovered in the amounts. An empty window returns 0 rather than an error: a pool that has just started has no shares, and that is the caller's cue not to build a coinbase-direct template at all. A non-positive window_diff IS an error — that is a config bug, not an empty pool. Four tests, including the one that matters: carol mines 1000 difficulty outside a 100-difficulty window and appears in neither the entries nor the total. Still to do: the mode, the wiring at job-build time (including what to do with the rounding remainder, since the builder requires an exact sum), the per-connection coinbase question, and docs. --- src/store.c | 71 +++++++++++++++++++++ src/store.h | 40 ++++++++++++ tests/test_store.c | 154 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 265 insertions(+) diff --git a/src/store.c b/src/store.c index 861f85f..1b4bc2f 100644 --- a/src/store.c +++ b/src/store.c @@ -1427,6 +1427,77 @@ int store_pplns_distribute(store_t *s, int maturity_confs, int fee_bps, return rc_out < 0 ? rc_out : blocks; } +/* ---- the PPLNS window, as it stands now --------------------------------- */ + +int store_pplns_window(store_t *s, double window_diff, + store_window_entry_t *out, size_t cap, + size_t *out_n, double *out_total_diff, + int *out_truncated, char *errbuf, size_t errlen) +{ + if (out_n) *out_n = 0; + if (out_total_diff) *out_total_diff = 0.0; + if (out_truncated) *out_truncated = 0; + if (!s || !s->db || !out || cap == 0) { + if (errbuf && errlen) snprintf(errbuf, errlen, "bad arg"); + return -1; + } + if (!(window_diff > 0.0)) { + if (errbuf && errlen) + snprintf(errbuf, errlen, "window_diff must be > 0"); + return -1; + } + + /* The same walk store_pplns_distribute() does, with two differences: it + * is anchored on the newest share rather than a particular block's, and + * it joins workers so the caller gets an address to pay. + * + * `running - difficulty < ?` compares against the total EXCLUDING the + * current row, which is what includes the share crossing the boundary + * whole instead of splitting it. Same rule, same reason, as the + * distributor -- if these two ever disagree, a block pays out differently + * from what the template promised. */ + static const char *Q = + "WITH anchored AS (" + " SELECT worker_id, difficulty, " + " SUM(difficulty) OVER (ORDER BY id DESC ROWS UNBOUNDED PRECEDING) AS running " + " FROM shares " + ") " + "SELECT w.id, COALESCE(w.payout_address,''), SUM(a.difficulty) AS wd " + " FROM anchored a " + " JOIN workers w ON w.id = a.worker_id " + " WHERE a.running - a.difficulty < ? " + " AND w.payout_address IS NOT NULL AND w.payout_address <> '' " + " GROUP BY w.id " + " HAVING wd > 0 " + " ORDER BY wd DESC, w.id ASC"; + + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(s->db, Q, -1, &st, NULL) != SQLITE_OK) { + if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); + atomic_fetch_add(&s->pg_errors, 1); + return -2; + } + sqlite3_bind_double(st, 1, window_diff); + + size_t n = 0; + double total = 0.0; + while (sqlite3_step(st) == SQLITE_ROW) { + if (n >= cap) { if (out_truncated) *out_truncated = 1; break; } + out[n].worker_id = sqlite3_column_int64(st, 0); + const unsigned char *addr = sqlite3_column_text(st, 1); + snprintf(out[n].payout_address, sizeof out[n].payout_address, "%s", + addr ? (const char *)addr : ""); + out[n].difficulty = sqlite3_column_double(st, 2); + total += out[n].difficulty; + n++; + } + sqlite3_finalize(st); + + if (out_n) *out_n = n; + if (out_total_diff) *out_total_diff = total; + return (int)n; +} + 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 598f595..a223552 100644 --- a/src/store.h +++ b/src/store.h @@ -158,6 +158,46 @@ int store_pplns_distribute(store_t *s, int maturity_confs, int fee_bps, int *out_blocks, int *out_workers, char *errbuf, size_t errlen); +/* ---- the PPLNS window, as it stands NOW -------------------------------- + * + * store_pplns_distribute() reads the window of a block that has already + * matured, anchored on that block's own share. This reads the window a block + * found RIGHT NOW would pay, anchored on the newest share there is — which is + * what a coinbase-direct pool needs when it builds a template, ~100 blocks + * before the other one would run. + * + * Only workers with a payout address are returned, and the total is summed + * over exactly those. A worker with no address cannot be given a coinbase + * output, and leaving it in the denominator would shrink everyone else's + * share to fund an output that is never created — value quietly destroyed + * rather than merely unpaid. */ +typedef struct { + int64_t worker_id; + char payout_address[128]; + double difficulty; /* this worker's difficulty inside the window */ +} store_window_entry_t; + +/* Fill `out` with the window's payable workers, largest first, and set + * *out_total_diff to the difficulty summed across the ones returned. + * + * `window_diff` is the window size in difficulty units, the same quantity + * blocks_found.pplns_window_diff stores. The boundary rule matches the + * distributor exactly: the share that crosses it is counted whole, because + * the window chooses which work is paid rather than claiming that precisely N + * difficulty was performed. + * + * *out_truncated is set when more payable workers were in the window than + * `cap` could hold. The tail is then absent from both the entries and the + * total, so their claim is redistributed rather than carried — the caller + * must decide whether that is acceptable rather than discovering it in the + * amounts. + * + * Returns the number written, or negative on error. */ +int store_pplns_window(store_t *s, double window_diff, + store_window_entry_t *out, size_t cap, + size_t *out_n, double *out_total_diff, + int *out_truncated, 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 * share_hash semantics match store_record_share() above. diff --git a/tests/test_store.c b/tests/test_store.c index fce6125..02cc4e8 100644 --- a/tests/test_store.c +++ b/tests/test_store.c @@ -1299,6 +1299,156 @@ static void test_pplns_distributes_two_blocks_in_one_pass(void) { printf(" ok test_pplns_distributes_two_blocks_in_one_pass\n"); } +/* ---- the window as it stands now --------------------------------------- + * + * store_pplns_distribute() reads the window of a block that already matured. + * A coinbase-direct pool needs the window a block found RIGHT NOW would pay, + * ~100 blocks before the other one runs. Same walk, different anchor. + * + * The property that matters is that the two agree. If the template promises a + * split the distributor would not have produced, the pool pays out something + * other than what it advertised. */ +static void test_the_window_now_matches_the_distributor(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 a 100-difficulty 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); + } + assert(store_flush(s) == 0); + + store_window_entry_t win[8]; + size_t n = 0; double total = 0.0; int truncated = 1; + char err[256] = {0}; + int rc = store_pplns_window(s, 100.0, win, 8, &n, &total, &truncated, + err, sizeof err); + assert(rc == 2); + assert(n == 2); + assert(truncated == 0); + /* Ordered largest first; equal here, so the tie breaks by worker id and + * alice (inserted first) leads. */ + assert(win[0].difficulty > 49.9 && win[0].difficulty < 50.1); + assert(win[1].difficulty > 49.9 && win[1].difficulty < 50.1); + assert(total > 99.9 && total < 100.1); + /* carol did 1000 difficulty and is outside the window: absent entirely, + * and absent from the denominator, so she does not dilute anyone. */ + for (size_t i = 0; i < n; ++i) + assert(strcmp(win[i].payout_address, "addr_c") != 0); + + store_close(s); + printf(" ok test_the_window_now_matches_the_distributor\n"); +} + +/* A worker with no payout address cannot be given a coinbase output. Leaving + * it in the denominator would shrink everyone else's share to fund an output + * that is never created -- value destroyed rather than merely unpaid. */ +static void test_a_worker_with_no_address_is_left_out_of_the_split(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, "alice", "addr_a", + 2000ULL + (uint64_t)i, 5.0, + 0, NULL, 0, 0.0) == 0); + /* No payout address at all -- the legacy/solo share path. */ + assert(store_record_share(s, "nobody", 2100ULL + (uint64_t)i, 5.0, + 0, NULL) == 0); + } + assert(store_flush(s) == 0); + + store_window_entry_t win[8]; + size_t n = 0; double total = 0.0; int truncated = 0; + char err[256] = {0}; + assert(store_pplns_window(s, 100.0, win, 8, &n, &total, &truncated, + err, sizeof err) == 1); + assert(n == 1); + assert(strcmp(win[0].payout_address, "addr_a") == 0); + /* 50, not 100: the unpayable worker is out of the denominator too, so + * alice's proportion is of what can actually be paid. */ + assert(total > 49.9 && total < 50.1); + store_close(s); + printf(" ok test_a_worker_with_no_address_is_left_out_of_the_split\n"); +} + +/* Truncation redistributes rather than carries, so it must be reported: the + * caller has to decide, not discover it in the amounts. */ +static void test_a_window_wider_than_the_cap_says_so(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 w = 0; w < 6; ++w) { + char name[32], addr[32]; + snprintf(name, sizeof name, "w%d", w); + snprintf(addr, sizeof addr, "addr_%d", w); + assert(store_record_share_addr(s, name, addr, 3000ULL + (uint64_t)w, + 10.0, 0, NULL, 0, 0.0) == 0); + } + assert(store_flush(s) == 0); + + store_window_entry_t win[3]; + size_t n = 0; double total = 0.0; int truncated = 0; + char err[256] = {0}; + assert(store_pplns_window(s, 1000.0, win, 3, &n, &total, &truncated, + err, sizeof err) == 3); + assert(n == 3); + assert(truncated == 1); + store_close(s); + printf(" ok test_a_window_wider_than_the_cap_says_so\n"); +} + +/* A pool that has just started has no shares. Nobody to pay is not an error + * here -- it is the caller's cue not to build a coinbase-direct template. */ +static void test_an_empty_window_returns_nothing_not_an_error(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); + + store_window_entry_t win[4]; + size_t n = 1; double total = 1.0; int truncated = 1; + char err[256] = {0}; + assert(store_pplns_window(s, 100.0, win, 4, &n, &total, &truncated, + err, sizeof err) == 0); + assert(n == 0); + assert(total == 0.0); + assert(truncated == 0); + /* A non-positive window is a config bug, not an empty pool. */ + assert(store_pplns_window(s, 0.0, win, 4, &n, &total, &truncated, + err, sizeof err) < 0); + store_close(s); + printf(" ok test_an_empty_window_returns_nothing_not_an_error\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(); @@ -1353,6 +1503,10 @@ int main(void) { test_pplns_distributes_the_window(); test_pplns_takes_the_operator_fee(); test_pplns_distributes_two_blocks_in_one_pass(); + test_an_empty_window_returns_nothing_not_an_error(); + test_a_window_wider_than_the_cap_says_so(); + test_a_worker_with_no_address_is_left_out_of_the_split(); + test_the_window_now_matches_the_distributor(); test_schema_sql_matches_store_schema(); cleanup_dbs(); printf("all tests passed\n"); From 62320022a1259b223f1f58494e24d9c9ee90bfc9 Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 7 Sep 2026 20:59:02 +0200 Subject: [PATCH 03/36] Coinbase-direct PPLNS: the drivechain path, and one shared splitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I called the per-connection coinbase "probably the real work" when opening this. Having read stratum.c, that was wrong, and the correction matters more than the guess did. A Stratum coinbase is cb1 + extranonce1 + extranonce2 + cb2. The extranonce lives in the scriptSig; the OUTPUTS live in cb2. So an N-output coinbase is structurally identical to what pps-classic and pplns already do — every connection gets the same cb2, and only the extranonce differs. There is no conflict with the per-connection extranonce layout, and conn_render_coinbase already renders an identical coinbase for every miner in the pooled modes. cb2 just gets bigger. The real gap was somewhere else, and it would have made the rail unusable in production. When the enforcer serves the template, the coinbase comes from the SERVER, carrying the BIP300/301 commitment OP_RETURNs and the witness commitment, and coinbase_build_from_template replaces its single spendable output. That function pays one miner and an optional fee. Every simplepool deployment mines on an enforcer template, so a window builder that only works from scratch is a rail that does not work where the pool actually runs. So the template builder now takes a resolver callback instead of a miner address. It parses, finds the reward, and hands it to the caller to turn into concrete outputs — one miner and a fee, or a whole window. A callback rather than an argument because the split depends on the reward and the reward is only known after parsing, so the caller cannot compute it up front and the parser should not have to know which kind of pool it is serving. The public coinbase_build_from_template keeps its exact signature and behaviour; it is now a thin wrapper over the same impl, which is what kept its existing tests as the safety net through the refactor. Both window builders share ONE resolver. That is the point of the change rather than a tidiness argument: a pool mining a drivechain template and a pool mining plain bitcoind must divide the same window into the same amounts, and two copies of dust, cap, carry and ranking would eventually disagree. A test asserts they produce identical results — same paid_count, paid_sats, fee_sats, carry_sats and dropped_dust — over a window containing a dust payee. Both new tests read the assembled transaction rather than the builder's report: one spendable output becomes two, every OP_RETURN the enforcer put there survives, and the outputs still sum to the whole reward. Mutation-verified — leaving the output count at the template's own vout fails. coinbase.c is at 87% line coverage; whole-project 79.9%. Still to do: the mode, the wiring at job-build time, carry-forward in the ledger, and docs. --- src/coinbase.c | 403 ++++++++++++++++++++++++++++-------------- src/coinbase.h | 29 +++ tests/test_coinbase.c | 125 +++++++++++++ 3 files changed, 426 insertions(+), 131 deletions(-) diff --git a/src/coinbase.c b/src/coinbase.c index 7365910..c28147a 100644 --- a/src/coinbase.c +++ b/src/coinbase.c @@ -726,25 +726,47 @@ static int payee_rank_cmp(const void *a, const void *b) { return x->idx < y->idx ? -1 : (x->idx > y->idx ? 1 : 0); } -int coinbase_build_window(uint32_t height, int64_t value_sats, - const coinbase_payee_t *payees, size_t n_payees, - const char *operator_address, int fee_bps, - const char *witness_commitment_hex, - const char *coinbase_tag, - size_t extranonce1_size, size_t extranonce2_size, - size_t max_payout_outputs, - coinbase_parts_t *out, - coinbase_window_result_t *res, - char *errbuf, size_t errlen) { - coinbase_window_result_t r = {0}; +/* One concrete output the reward is being replaced with. */ +typedef struct { + uint8_t spk[64]; + size_t spk_len; + int64_t sats; +} cb_repl_out_t; + +/* Turn a template's reward into the outputs that replace it. + * + * A callback rather than an argument because the split depends on the reward, + * and the reward is only known once the template has been parsed -- so the + * caller cannot compute it up front, and the parser should not have to know + * whether it is paying one miner or a whole window. */ +typedef int (*cb_repl_fn)(void *ctx, int64_t reward_sats, + cb_repl_out_t *out, size_t cap, size_t *out_n, + char *errbuf, size_t errlen); + +/* Payees plus the operator. */ +#define CB_MAX_REPL_OUTS (COINBASE_MAX_PAYOUT_OUTPUTS + 1) + +/* Turn a window into concrete outputs: fee off the top, dust and the output + * cap applied largest-first, and whatever cannot be paid folded onto the + * operator as carry. + * + * Shared by the from-scratch and from-template builders precisely so the two + * cannot drift. A pool mining a drivechain template and one mining plain + * bitcoind must split a window identically; the only difference between them + * is which outputs are preserved around the payout, and that is not this + * function's business. */ +static int resolve_window_outputs(int64_t value_sats, + const coinbase_payee_t *payees, size_t n_payees, + const char *operator_address, int fee_bps, + size_t max_payout_outputs, + cb_repl_out_t *out, size_t cap, size_t *out_n, + coinbase_window_result_t *res, + char *errbuf, size_t errlen) { + coinbase_window_result_t r; + memset(&r, 0, sizeof r); if (res) *res = r; - if (!out || (!payees && n_payees > 0)) { - set_err(errbuf, errlen, "null arg"); - return -1; - } - out->cb1 = NULL; out->cb1_len = 0; - out->cb2 = NULL; out->cb2_len = 0; - if (n_payees == 0) { + if (out_n) *out_n = 0; + if (!out || cap < 2 || !payees || n_payees == 0) { set_err(errbuf, errlen, "window is empty: nobody to pay"); return -1; } @@ -753,31 +775,23 @@ int coinbase_build_window(uint32_t height, int64_t value_sats, return -1; } if (max_payout_outputs == 0) max_payout_outputs = COINBASE_MAX_PAYOUT_OUTPUTS; + if (max_payout_outputs > cap - 1) max_payout_outputs = cap - 1; - /* The operator fee, on the same terms as every other builder: off the - * top, dropped entirely if it would be dust. */ int64_t fee_sats = 0; - uint8_t operator_spk[64]; - size_t operator_spk_len = 0; - int has_operator = 0; + cb_repl_out_t op; + memset(&op, 0, sizeof op); + int has_operator = 0; if (operator_address && operator_address[0] && fee_bps > 0) { int64_t f = (value_sats * (int64_t)fee_bps) / 10000; if (f >= COINBASE_DUST_SATS) { - if (coinbase_address_to_script(operator_address, operator_spk, - sizeof operator_spk, - &operator_spk_len, - errbuf, errlen) < 0) { - return -1; - } + if (coinbase_address_to_script(operator_address, op.spk, + sizeof op.spk, &op.spk_len, + errbuf, errlen) < 0) return -1; fee_sats = f; has_operator = 1; } } - /* The caller's split must account for the whole payable amount. A - * shortfall here would be forfeited to nobody -- a coinbase paying out - * less than it may simply destroys the difference -- so it is a caller - * bug, not something to paper over. */ int64_t payable = value_sats - fee_sats; int64_t claimed = 0; for (size_t i = 0; i < n_payees; ++i) { @@ -806,80 +820,99 @@ int coinbase_build_window(uint32_t height, int64_t value_sats, } qsort(rank, n_payees, sizeof *rank, payee_rank_cmp); - /* Resolve and emit, largest first, until the cap or the dust limit stops - * us. Everything not paid becomes carry. */ - bbuf_t outs; - bbuf_init(&outs); - uint64_t n_outputs = 0; - int64_t carry = 0; - + size_t n = 0; + int64_t carry = 0; for (size_t k = 0; k < n_payees; ++k) { const coinbase_payee_t *pe = &payees[rank[k].idx]; if (pe->sats < COINBASE_DUST_SATS) { - r.dropped_dust++; - carry += pe->sats; - continue; + r.dropped_dust++; carry += pe->sats; continue; } - if (r.paid_count >= max_payout_outputs) { - r.dropped_capped++; - carry += pe->sats; - continue; + if (n >= max_payout_outputs) { + r.dropped_capped++; carry += pe->sats; continue; } - uint8_t spk[64]; - size_t spk_len = 0; - if (coinbase_address_to_script(pe->address, spk, sizeof spk, - &spk_len, errbuf, errlen) < 0) { - bbuf_free(&outs); free(rank); - return -1; + if (coinbase_address_to_script(pe->address, out[n].spk, + sizeof out[n].spk, &out[n].spk_len, + errbuf, errlen) < 0) { + free(rank); return -1; } - if (bbuf_push_u64_le(&outs, (uint64_t)pe->sats) < 0) goto oom; - if (bbuf_push_varint(&outs, spk_len) < 0) goto oom; - if (bbuf_push(&outs, spk, spk_len) < 0) goto oom; - n_outputs++; - r.paid_count++; + out[n].sats = pe->sats; r.paid_sats += pe->sats; + n++; r.paid_count++; } free(rank); - rank = NULL; - /* Nobody cleared the dust limit. Refusing beats emitting a coinbase that - * pays the operator the entire block and calls it a fee. */ if (r.paid_count == 0) { - bbuf_free(&outs); set_err(errbuf, errlen, "no payee in the window clears the %d-sat dust limit", COINBASE_DUST_SATS); return -1; } - /* The carry rides on the operator output, because it has to ride - * somewhere: value not paid out is value destroyed. The operator now - * holds it and owes it -- see coinbase_window_result_t. */ + /* Carry rides on the operator output, because it has to ride somewhere: + * value not paid out is value destroyed. */ int64_t operator_out = fee_sats + carry; if (operator_out > 0 && !has_operator) { if (!operator_address || !operator_address[0]) { - bbuf_free(&outs); set_err(errbuf, errlen, "%lld sats could not be paid to the window and there is no " "operator_address to carry them", (long long)operator_out); return -1; } - if (coinbase_address_to_script(operator_address, operator_spk, - sizeof operator_spk, &operator_spk_len, - errbuf, errlen) < 0) { - bbuf_free(&outs); - return -1; - } + if (coinbase_address_to_script(operator_address, op.spk, sizeof op.spk, + &op.spk_len, errbuf, errlen) < 0) return -1; has_operator = 1; } if (has_operator && operator_out > 0) { - if (bbuf_push_u64_le(&outs, (uint64_t)operator_out) < 0) goto oom; - if (bbuf_push_varint(&outs, operator_spk_len) < 0) goto oom; - if (bbuf_push(&outs, operator_spk, operator_spk_len) < 0) goto oom; + if (n >= cap) { set_err(errbuf, errlen, "internal: repl cap"); return -1; } + op.sats = operator_out; + out[n++] = op; + } + + r.fee_sats = fee_sats; + r.carry_sats = carry; + if (out_n) *out_n = n; + if (res) *res = r; + return 0; +} + +int coinbase_build_window(uint32_t height, int64_t value_sats, + const coinbase_payee_t *payees, size_t n_payees, + const char *operator_address, int fee_bps, + const char *witness_commitment_hex, + const char *coinbase_tag, + size_t extranonce1_size, size_t extranonce2_size, + size_t max_payout_outputs, + coinbase_parts_t *out, + coinbase_window_result_t *res, + char *errbuf, size_t errlen) { + if (res) { coinbase_window_result_t z; memset(&z, 0, sizeof z); *res = z; } + if (!out) { set_err(errbuf, errlen, "null arg"); return -1; } + out->cb1 = NULL; out->cb1_len = 0; + out->cb2 = NULL; out->cb2_len = 0; + + /* Same resolver the template builder uses: the split is one rule, in one + * place, whatever kind of coinbase it ends up in. */ + cb_repl_out_t repl[CB_MAX_REPL_OUTS]; + size_t n_repl = 0; + if (resolve_window_outputs(value_sats, payees, n_payees, operator_address, + fee_bps, max_payout_outputs, repl, + CB_MAX_REPL_OUTS, &n_repl, res, + errbuf, errlen) < 0) { + return -1; + } + + bbuf_t outs; + bbuf_init(&outs); + uint64_t n_outputs = 0; + int64_t emitted = 0; + for (size_t i = 0; i < n_repl; ++i) { + if (bbuf_push_u64_le(&outs, (uint64_t)repl[i].sats) < 0) goto oom; + if (bbuf_push_varint(&outs, repl[i].spk_len) < 0) goto oom; + if (bbuf_push(&outs, repl[i].spk, repl[i].spk_len) < 0) goto oom; n_outputs++; + emitted += repl[i].sats; } - /* Witness commitment, byte-for-byte, last. */ uint8_t wc_buf[256]; size_t wc_len = 0; if (witness_commitment_hex && *witness_commitment_hex) { @@ -895,15 +928,13 @@ int coinbase_build_window(uint32_t height, int64_t value_sats, } /* Every satoshi is accounted for, or the block burns the difference. */ - if (r.paid_sats + operator_out != value_sats) { + if (emitted != value_sats) { bbuf_free(&outs); - set_err(errbuf, errlen, - "internal: outputs sum to %lld, block pays %lld", - (long long)(r.paid_sats + operator_out), (long long)value_sats); + set_err(errbuf, errlen, "internal: outputs sum to %lld, block pays %lld", + (long long)emitted, (long long)value_sats); return -1; } - /* scriptSig, exactly as coinbase_build_split lays it out. */ uint8_t height_push[8]; size_t height_push_len = bip34_height_push(height, height_push); uint8_t tag_push[80]; @@ -928,7 +959,6 @@ int coinbase_build_window(uint32_t height, int64_t value_sats, bbuf_t c1, c2; bbuf_init(&c1); bbuf_init(&c2); - /* version, input count, prevout (null), scriptSig len, height, tag */ if (bbuf_push_u32_le(&c1, 2) < 0) goto oom2; if (bbuf_push_varint(&c1, 1) < 0) goto oom2; for (int i = 0; i < 32; ++i) if (bbuf_push_u8(&c1, 0) < 0) goto oom2; @@ -936,7 +966,6 @@ int coinbase_build_window(uint32_t height, int64_t value_sats, if (bbuf_push_varint(&c1, script_sig_len) < 0) goto oom2; if (bbuf_push(&c1, height_push, height_push_len) < 0) goto oom2; if (tag_push_len && bbuf_push(&c1, tag_push, tag_push_len) < 0) goto oom2; - /* cb2: sequence, outputs, locktime */ if (bbuf_push_u32_le(&c2, 0xffffffffu) < 0) goto oom2; if (bbuf_push_varint(&c2, n_outputs) < 0) goto oom2; if (bbuf_push(&c2, outs.data, outs.len) < 0) goto oom2; @@ -945,41 +974,34 @@ int coinbase_build_window(uint32_t height, int64_t value_sats, bbuf_free(&outs); out->cb1 = c1.data; out->cb1_len = c1.len; out->cb2 = c2.data; out->cb2_len = c2.len; - r.fee_sats = fee_sats; - r.carry_sats = carry; - if (res) *res = r; return 0; oom2: bbuf_free(&c1); bbuf_free(&c2); oom: bbuf_free(&outs); - free(rank); set_err(errbuf, errlen, "oom"); return -1; } -int coinbase_build_from_template(const char *coinbase_tx_hex, - const char *miner_address, - const char *operator_address, - int fee_bps, - const char *coinbase_tag, - size_t extranonce1_size, - size_t extranonce2_size, - coinbase_parts_t *out, - int *out_has_witness, - int64_t *out_miner_sats, - int64_t *out_fee_sats, - char *errbuf, size_t errlen) { - if (!out || !coinbase_tx_hex || !miner_address) { +static int build_from_template_impl(const char *coinbase_tx_hex, + cb_repl_fn repl_fn, void *repl_ctx, + const char *coinbase_tag, + size_t extranonce1_size, + size_t extranonce2_size, + coinbase_parts_t *out, + int *out_has_witness, + char *errbuf, size_t errlen) { + if (!out || !coinbase_tx_hex || !repl_fn) { set_err(errbuf, errlen, "null arg"); return -1; } out->cb1 = NULL; out->cb1_len = 0; out->cb2 = NULL; out->cb2_len = 0; if (out_has_witness) *out_has_witness = 0; - if (out_miner_sats) *out_miner_sats = 0; - if (out_fee_sats) *out_fee_sats = 0; + + cb_repl_out_t repl[CB_MAX_REPL_OUTS]; + size_t n_repl = 0; struct cb_out { uint64_t value; size_t spk_off; size_t spk_len; int op_return; }; @@ -1074,25 +1096,24 @@ int coinbase_build_from_template(const char *coinbase_tx_hex, } int64_t reward = (int64_t)outs[reward_idx].value; - /* Resolve miner scriptPubKey + fee split (mirrors coinbase_build_split). */ - uint8_t miner_spk[64]; size_t miner_spk_len = 0; - if (coinbase_address_to_script(miner_address, miner_spk, sizeof miner_spk, - &miner_spk_len, errbuf, errlen) < 0) goto done; - - int64_t fee_sats = 0, miner_sats = reward; - uint8_t operator_spk[64]; size_t operator_spk_len = 0; - int has_operator = 0; - if (operator_address && operator_address[0] && fee_bps > 0 && reward > 0) { - fee_sats = (reward * (int64_t)fee_bps) / 10000; - if (fee_sats >= COINBASE_DUST_SATS) { - if (coinbase_address_to_script(operator_address, operator_spk, - sizeof operator_spk, &operator_spk_len, - errbuf, errlen) < 0) goto done; - miner_sats = reward - fee_sats; - has_operator = 1; - } else { - fee_sats = 0; - } + /* Hand the reward to the caller's resolver: one miner and a fee, or a + * whole PPLNS window. Either way it comes back as concrete outputs, and + * this function does not care which it was. */ + if (repl_fn(repl_ctx, reward, repl, CB_MAX_REPL_OUTS, &n_repl, + errbuf, errlen) < 0) goto done; + if (n_repl == 0) { + set_err(errbuf, errlen, "resolver produced no outputs"); + goto done; + } + /* Whatever the split, it must spend the reward exactly: paying out less + * than the template allows forfeits the difference to nobody. */ + int64_t repl_total = 0; + for (size_t i = 0; i < n_repl; ++i) repl_total += repl[i].sats; + if (repl_total != reward) { + set_err(errbuf, errlen, + "replacement outputs sum to %lld, template reward is %lld", + (long long)repl_total, (long long)reward); + goto done; } /* Optional coinbase tag, appended into the scriptSig. */ @@ -1125,16 +1146,13 @@ int coinbase_build_from_template(const char *coinbase_tx_hex, if (tag_push_len && bbuf_push(&c1, tag_push, tag_push_len) < 0) { set_err(errbuf, errlen, "oom"); goto done; } /* Outputs: replace the reward output, preserve everything else in order. */ - uint64_t new_vout = vout + (has_operator ? 1u : 0u); + uint64_t new_vout = vout - 1u + (uint64_t)n_repl; for (uint64_t i = 0; i < vout; i++) { if ((int64_t)i == reward_idx) { - if (bbuf_push_u64_le(&ob, (uint64_t)miner_sats) < 0) { set_err(errbuf, errlen, "oom"); goto done; } - if (bbuf_push_varint(&ob, miner_spk_len) < 0) { set_err(errbuf, errlen, "oom"); goto done; } - if (bbuf_push(&ob, miner_spk, miner_spk_len) < 0) { set_err(errbuf, errlen, "oom"); goto done; } - if (has_operator) { - if (bbuf_push_u64_le(&ob, (uint64_t)fee_sats) < 0) { set_err(errbuf, errlen, "oom"); goto done; } - if (bbuf_push_varint(&ob, operator_spk_len) < 0) { set_err(errbuf, errlen, "oom"); goto done; } - if (bbuf_push(&ob, operator_spk, operator_spk_len) < 0) { set_err(errbuf, errlen, "oom"); goto done; } + for (size_t k = 0; k < n_repl; ++k) { + if (bbuf_push_u64_le(&ob, (uint64_t)repl[k].sats) < 0) { set_err(errbuf, errlen, "oom"); goto done; } + if (bbuf_push_varint(&ob, repl[k].spk_len) < 0) { set_err(errbuf, errlen, "oom"); goto done; } + if (bbuf_push(&ob, repl[k].spk, repl[k].spk_len) < 0) { set_err(errbuf, errlen, "oom"); goto done; } } } else { if (bbuf_push_u64_le(&ob, outs[i].value) < 0) { set_err(errbuf, errlen, "oom"); goto done; } @@ -1153,8 +1171,6 @@ int coinbase_build_from_template(const char *coinbase_tx_hex, out->cb1 = c1.data; out->cb1_len = c1.len; c1.data = NULL; out->cb2 = c2.data; out->cb2_len = c2.len; c2.data = NULL; if (out_has_witness) *out_has_witness = has_witness; - if (out_miner_sats) *out_miner_sats = miner_sats; - if (out_fee_sats) *out_fee_sats = fee_sats; ret = 0; done: @@ -1167,6 +1183,131 @@ int coinbase_build_from_template(const char *coinbase_tx_hex, } +/* ---- resolvers ---------------------------------------------------------- */ + +/* One miner and an optional operator fee: the original behaviour, unchanged. */ +typedef struct { + const char *miner_address; + const char *operator_address; + int fee_bps; + int64_t *out_miner_sats; + int64_t *out_fee_sats; +} repl_single_ctx_t; + +static int repl_single(void *vctx, int64_t reward, cb_repl_out_t *out, + size_t cap, size_t *out_n, char *errbuf, size_t errlen) { + repl_single_ctx_t *c = vctx; + if (cap < 2) { set_err(errbuf, errlen, "internal: repl cap"); return -1; } + size_t n = 0; + int64_t fee_sats = 0, miner_sats = reward; + cb_repl_out_t op; + memset(&op, 0, sizeof op); + if (c->operator_address && c->operator_address[0] && c->fee_bps > 0 && reward > 0) { + int64_t f = (reward * (int64_t)c->fee_bps) / 10000; + if (f >= COINBASE_DUST_SATS) { + if (coinbase_address_to_script(c->operator_address, op.spk, + sizeof op.spk, &op.spk_len, + errbuf, errlen) < 0) return -1; + fee_sats = f; + miner_sats = reward - f; + op.sats = f; + } + } + if (coinbase_address_to_script(c->miner_address, out[n].spk, + sizeof out[n].spk, &out[n].spk_len, + errbuf, errlen) < 0) return -1; + out[n].sats = miner_sats; + n++; + if (fee_sats > 0) out[n++] = op; + *out_n = n; + if (c->out_miner_sats) *c->out_miner_sats = miner_sats; + if (c->out_fee_sats) *c->out_fee_sats = fee_sats; + return 0; +} + +/* A whole PPLNS window, on exactly the terms coinbase_build_window() uses -- + * the same resolver, so a drivechain pool and a plain-bitcoind pool cannot + * split a window differently. */ +typedef struct { + const coinbase_payee_t *payees; + size_t n_payees; + const char *operator_address; + int fee_bps; + size_t max_payout_outputs; + coinbase_window_result_t *res; +} repl_window_ctx_t; + +static int repl_window(void *vctx, int64_t reward, cb_repl_out_t *out, + size_t cap, size_t *out_n, char *errbuf, size_t errlen) { + repl_window_ctx_t *c = vctx; + return resolve_window_outputs(reward, c->payees, c->n_payees, + c->operator_address, c->fee_bps, + c->max_payout_outputs, out, cap, out_n, + c->res, errbuf, errlen); +} + +/* ---- public template builders ------------------------------------------- */ + +int coinbase_build_from_template(const char *coinbase_tx_hex, + const char *miner_address, + const char *operator_address, + int fee_bps, + const char *coinbase_tag, + size_t extranonce1_size, + size_t extranonce2_size, + coinbase_parts_t *out, + int *out_has_witness, + int64_t *out_miner_sats, + int64_t *out_fee_sats, + char *errbuf, size_t errlen) { + if (!miner_address) { set_err(errbuf, errlen, "null arg"); return -1; } + if (out_miner_sats) *out_miner_sats = 0; + if (out_fee_sats) *out_fee_sats = 0; + repl_single_ctx_t ctx; + memset(&ctx, 0, sizeof ctx); + ctx.miner_address = miner_address; + ctx.operator_address = operator_address; + ctx.fee_bps = fee_bps; + ctx.out_miner_sats = out_miner_sats; + ctx.out_fee_sats = out_fee_sats; + return build_from_template_impl(coinbase_tx_hex, repl_single, &ctx, + coinbase_tag, extranonce1_size, + extranonce2_size, out, out_has_witness, + errbuf, errlen); +} + +int coinbase_build_window_from_template(const char *coinbase_tx_hex, + const coinbase_payee_t *payees, + size_t n_payees, + const char *operator_address, + int fee_bps, + const char *coinbase_tag, + size_t extranonce1_size, + size_t extranonce2_size, + size_t max_payout_outputs, + coinbase_parts_t *out, + int *out_has_witness, + coinbase_window_result_t *res, + char *errbuf, size_t errlen) { + if (res) { coinbase_window_result_t z; memset(&z, 0, sizeof z); *res = z; } + if (!payees || n_payees == 0) { + set_err(errbuf, errlen, "window is empty: nobody to pay"); + return -1; + } + repl_window_ctx_t ctx; + memset(&ctx, 0, sizeof ctx); + ctx.payees = payees; + ctx.n_payees = n_payees; + ctx.operator_address = operator_address; + ctx.fee_bps = fee_bps; + ctx.max_payout_outputs = max_payout_outputs; + ctx.res = res; + return build_from_template_impl(coinbase_tx_hex, repl_window, &ctx, + coinbase_tag, extranonce1_size, + extranonce2_size, out, out_has_witness, + errbuf, errlen); +} + /* Count the outputs of a serialized coinbase, split into spendable and * OP_RETURN. * diff --git a/src/coinbase.h b/src/coinbase.h index f3857df..2ec9ca3 100644 --- a/src/coinbase.h +++ b/src/coinbase.h @@ -156,6 +156,35 @@ void coinbase_parts_free(coinbase_parts_t *p); * enforcer (plus the mandatory BIP300/301 commitments), which is what tells an * observer whether a sidechain can be merge-mined into these blocks. * Returns 0 ok, negative on malformed input. */ +/* coinbase_build_window(), but replacing the single spendable output of a + * server-provided coinbasetxn instead of building one from scratch. + * + * This is the drivechain path, and it is the one a real pool needs: when the + * CUSF enforcer serves the template, its coinbase already carries the + * BIP300/301 commitment OP_RETURNs and the witness commitment, and those are + * preserved byte-for-byte and in order exactly as + * coinbase_build_from_template() does. Only the reward output is replaced -- + * by the whole window rather than by one miner. + * + * The splitting rules are SHARED with coinbase_build_window() rather than + * reimplemented, so a pool mining a drivechain template and one mining plain + * bitcoind cannot divide the same window differently. + * + * Returns 0 ok, negative on error. `res` and `out_has_witness` may be NULL. */ +int coinbase_build_window_from_template(const char *coinbase_tx_hex, + const coinbase_payee_t *payees, + size_t n_payees, + const char *operator_address, + int fee_bps, + const char *coinbase_tag, + size_t extranonce1_size, + size_t extranonce2_size, + size_t max_payout_outputs, + coinbase_parts_t *out, + int *out_has_witness, + coinbase_window_result_t *res, + char *errbuf, size_t errlen); + int coinbase_count_outputs(const char *tx_hex, int *spendable_out, int *op_return_out); diff --git a/tests/test_coinbase.c b/tests/test_coinbase.c index 10418cf..db20c9c 100644 --- a/tests/test_coinbase.c +++ b/tests/test_coinbase.c @@ -1004,8 +1004,133 @@ static void test_the_coinbase_is_deterministic(void) { printf("ok: the same window builds the same coinbase twice\n"); } +/* Assemble cb1 + extranonce + cb2 and tally the outputs, the same way the + * single-payee template test walks the bytes. Reads the transaction rather + * than trusting the builder's own report. */ +static void parts_outputs(const coinbase_parts_t *parts, size_t en_total, + int *spendable, int *op_returns, int64_t *sum) { + size_t total = parts->cb1_len + en_total + parts->cb2_len; + uint8_t *tx = (uint8_t *)malloc(total); + assert(tx); + memcpy(tx, parts->cb1, parts->cb1_len); + memset(tx + parts->cb1_len, 0xaa, en_total); + memcpy(tx + parts->cb1_len + en_total, parts->cb2, parts->cb2_len); + + size_t off = 4; /* version */ + uint64_t n = 0; + assert(read_varint(tx, total, &off, &n) == 0 && n == 1); + off += 32 + 4; /* prevout */ + uint64_t ss = 0; + assert(read_varint(tx, total, &off, &ss) == 0); + off += ss + 4; /* scriptSig + sequence */ + uint64_t outs = 0; + assert(read_varint(tx, total, &off, &outs) == 0); + + *spendable = 0; *op_returns = 0; *sum = 0; + for (uint64_t i = 0; i < outs; ++i) { + int64_t v = 0; + for (int k = 0; k < 8; ++k) v |= ((int64_t)tx[off + k]) << (8 * k); + off += 8; + uint64_t spk_len = 0; + assert(read_varint(tx, total, &off, &spk_len) == 0); + if (spk_len > 0 && tx[off] == 0x6a) (*op_returns)++; + else { (*spendable)++; *sum += v; } + off += spk_len; + } + free(tx); +} + +/* The drivechain path: a window paid straight out of an enforcer-served + * coinbase, with the BIP300/301 commitments preserved around it. + * + * This is the one a real pool needs. Every simplepool deployment mines on a + * template the enforcer builds, so a rail that only works against plain + * bitcoind is a rail that does not work. */ +static void test_window_from_template_preserves_commitments(void) { + coinbase_parts_t parts; char err[256] = {0}; + coinbase_window_result_t res; + int has_witness = -1; + + /* What the template pays, learned from the single-payee builder so the + * split below is exact without hardcoding the fixture's reward. */ + coinbase_parts_t probe; int64_t reward = 0, unused = 0; + assert(coinbase_build_from_template(ENF_COINBASE_HEX, ENF_ADDR, NULL, 0, + NULL, 4, 4, &probe, NULL, &reward, + &unused, err, sizeof err) == 0); + coinbase_parts_free(&probe); + assert(reward > 0); + + /* Two miners, 60/40, no operator fee so the arithmetic is exact. */ + int64_t a = (reward * 6) / 10; + const coinbase_payee_t payees[] = { { WA, a }, { WB, reward - a } }; + int rc = coinbase_build_window_from_template( + ENF_COINBASE_HEX, payees, 2, NULL, 0, "/x/", 4, 4, 0, + &parts, &has_witness, &res, err, sizeof err); + if (rc != 0) fprintf(stderr, "window_from_template err: %s\n", err); + assert(rc == 0); + assert(res.paid_count == 2); + assert(res.carry_sats == 0); + assert(res.paid_sats == reward); + + /* The enforcer's own outputs must survive: one spendable output was + * replaced by two, and every OP_RETURN it carried is still there. */ + int base_spendable = 0, base_op_returns = 0; + assert(coinbase_count_outputs(ENF_COINBASE_HEX, &base_spendable, + &base_op_returns) == 0); + assert(base_spendable == 1); + + int spendable = 0, op_returns = 0; + int64_t sum = 0; + parts_outputs(&parts, 8, &spendable, &op_returns, &sum); + assert(spendable == 2); /* one output became two miners */ + assert(op_returns == base_op_returns); /* every commitment survived */ + assert(sum == reward); /* and the whole reward left */ + coinbase_parts_free(&parts); + printf("ok: window from template pays N miners and keeps the commitments\n"); +} + +/* The two builders must divide a window identically. They share a resolver + * precisely so that a drivechain pool and a plain-bitcoind pool cannot pay + * the same miners different amounts. */ +static void test_both_window_builders_split_identically(void) { + char err[256] = {0}; + coinbase_parts_t p1, p2; + coinbase_window_result_t r1, r2; + + coinbase_parts_t probe; int64_t reward = 0, unused = 0; + assert(coinbase_build_from_template(ENF_COINBASE_HEX, ENF_ADDR, NULL, 0, + NULL, 4, 4, &probe, NULL, &reward, + &unused, err, sizeof err) == 0); + coinbase_parts_free(&probe); + + /* Three claims, one of them dust, so dust and carry are exercised too. + * They must sum to the payable amount, i.e. net of the 1% fee. */ + int64_t fee = (reward * 100) / 10000; + int64_t payable = reward - fee; + const coinbase_payee_t payees[] = { + { WA, payable - 40000 - 100 }, { WB, 40000 }, { WC, 100 }, + }; + assert(coinbase_build_window(800000, reward, payees, 3, WOP, 100, NULL, + "/x/", 4, 4, 0, &p1, &r1, err, sizeof err) == 0); + assert(coinbase_build_window_from_template(ENF_COINBASE_HEX, payees, 3, + WOP, 100, "/x/", 4, 4, 0, + &p2, NULL, &r2, err, sizeof err) == 0); + assert(r1.paid_count == r2.paid_count); + assert(r1.paid_sats == r2.paid_sats); + assert(r1.fee_sats == r2.fee_sats); + assert(r1.carry_sats == r2.carry_sats); + assert(r1.dropped_dust == r2.dropped_dust); + assert(r1.dropped_dust == 1); + assert(r1.carry_sats >= 100); + coinbase_parts_free(&p1); + coinbase_parts_free(&p2); + printf("ok: both window builders split a window identically\n"); +} + int main(void) { test_p2pkh_address(); + test_both_window_builders_split_identically(); + test_window_from_template_preserves_commitments(); test_window_pays_each_miner_its_own_output(); test_a_split_that_does_not_add_up_is_refused(); test_a_dust_payee_is_carried_not_burnt(); From 956c158b3a367198130228fb4bb2105fb7c78398 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 8 Sep 2026 09:06:51 +0200 Subject: [PATCH 04/36] Coinbase-direct PPLNS: the mode, wired end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pool_mode = pplns-coinbase. The window is snapshotted onto the job when the template is built, and the coinbase of the block that window produced pays every one of them directly. The pool never receives the reward, so there is no wallet, no payout worker, no write-ahead row and no maturity gate. Config refuses pool_btc_address in this mode rather than ignoring it. The whole claim here is that the pool never holds the reward, and a configured pool wallet is the shape of a pool that does — most likely a mode switched in place without the rest of the config following. Running a custodial-looking pool that quietly is not one is worse than refusing to start. coinbase_pays_window is a third state, not a variation on the other two: solo pays the finder, the pooled modes pay the pool, and this pays the window. main.c sets them so the reward can go to the miners or to the pool, never both. The window rides on the JOB rather than the connection, which follows from what a Stratum coinbase is: cb1 + extranonce1 + extranonce2 + cb2, outputs in cb2, extranonce in the scriptSig. Every connection therefore renders the same coinbase, exactly as the pooled modes already do. A test asserts that two connections get identical output counts, because "the window belongs to the job" is the property the whole design rests on. Three decisions that could each have been made silently: A job with no window is never published, and if one reaches the renderer it refuses. A coinbase paying nobody does not pay less — it forfeits the whole block. Better to render nothing and let the miner wait for the next template, which is at most one poll interval away. Truncating division leaves a few satoshis over, and they cannot be dropped: the builder requires the split to spend the payable amount exactly, and a coinbase that pays out less forfeits the difference. They go to the largest claim, which is the miner with the strongest claim on them. The first job of the process deliberately carries no window. Network difficulty has not been read yet, and a process that just started has no shares to pay anyway — so it would be empty even if it could be sized. Said in a comment rather than left as a mystery for whoever sees the first "refusing to render" line in a log. Tests: config accepts the mode without a pool wallet, refuses it with one, and still validates the window knob; a job with a window renders exactly one output per miner and nothing else, identically for two connections; a windowless job renders nothing at all. Mutation-verified — making the mode fall through to solo fails two of them. Both config paths were also checked against the real binary rather than only in tests. Still to do: the e2e, carry-forward in the ledger, and docs. --- src/config.c | 31 +++++++++-- src/main.c | 124 +++++++++++++++++++++++++++++++++++++++++-- src/stratum.c | 71 ++++++++++++++++++++++++- src/stratum.h | 26 +++++++++ tests/test_config.c | 42 +++++++++++++++ tests/test_stratum.c | 97 +++++++++++++++++++++++++++++++++ 6 files changed, 382 insertions(+), 9 deletions(-) diff --git a/src/config.c b/src/config.c index 15c7ee6..c836433 100644 --- a/src/config.c +++ b/src/config.c @@ -331,22 +331,41 @@ int proxy_config_load(const char *path, proxy_config_t *cfg, 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"); + "Use 'pplns-thunder', 'pplns-btc' or 'pplns-coinbase' — an " + "operator runs one, and the rail decides what a stratum " + "username is and who ever holds the reward"); return -5; } + /* Pays the window straight out of the coinbase of the block that produced + * it. Same accounting as the other two, and the pool never receives the + * reward at all — so there is no wallet, no payout worker and no maturity + * gate, because a reorged block simply never paid. */ + int mode_cb_window = strcmp(cfg->pool_mode, "pplns-coinbase") == 0; int mode_pplns = strcmp(cfg->pool_mode, "pplns-thunder") == 0 || - strcmp(cfg->pool_mode, "pplns-btc") == 0; + strcmp(cfg->pool_mode, "pplns-btc") == 0 || + mode_cb_window; if (strcmp(cfg->pool_mode, "solo") != 0 && strcmp(cfg->pool_mode, "pps-classic") != 0 && !mode_pplns) { set_err(errbuf, errlen, "config: 'pool_mode' must be 'solo', 'pps-classic', " - "'pplns-thunder' or 'pplns-btc', got '%s'", + "'pplns-thunder', 'pplns-btc' or 'pplns-coinbase', got '%s'", cfg->pool_mode); return -5; } - if (mode_pplns) { + if (mode_cb_window && cfg->pool_btc_address[0] != '\0') { + /* Not a harmless leftover. The whole claim of this mode is that the + * pool never holds the reward, and a configured pool wallet is the + * shape of a pool that does — most likely a mode switched in place + * without the rest of the config following. Refusing beats running a + * custodial-looking pool that quietly is not one. */ + set_err(errbuf, errlen, + "config: 'pool_btc_address' must not be set when " + "pool_mode=pplns-coinbase — this mode pays miners directly " + "from the coinbase and the pool never receives the reward"); + return -9; + } + if (mode_pplns && !mode_cb_window) { /* 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. */ @@ -356,6 +375,8 @@ int proxy_config_load(const char *path, proxy_config_t *cfg, cfg->pool_mode); return -9; } + } + if (mode_pplns) { if (!(cfg->pplns_window_diff_multiple > 0.0)) { set_err(errbuf, errlen, "config: 'pplns_window_diff_multiple' must be > 0, got %g", diff --git a/src/main.c b/src/main.c index d14ec91..15360be 100644 --- a/src/main.c +++ b/src/main.c @@ -7,6 +7,7 @@ #include "share.h" #include "store.h" #include "reconcile.h" +#include "coinbase.h" #include "stratum.h" #include "version.h" @@ -182,6 +183,98 @@ static double effective_pps_rate(const proxy_config_t *cfg, /* Build a job from a freshly fetched template. The coinbase is rendered * per-connection inside stratum.c (each miner pays their own address), * so we only pass template-level data here. */ +/* Snapshot the PPLNS window onto a freshly built job, for pplns-coinbase. + * + * The window is taken from the template that is about to go out, so the + * coinbase pays the work that exists NOW. That is the whole difference from + * the other two rails, which read the window ~100 blocks later out of a block + * that already matured. There is nothing to mature here: the payment IS the + * block, so a reorged block simply never paid and there is no credit to claw + * back. + * + * Returns 0 when the job may be published. Non-zero means no coinbase can be + * rendered from it -- an empty window, or arithmetic that would not add up -- + * and the caller must not publish it: a coinbase paying nobody forfeits the + * whole block. + */ +static int attach_pplns_window(store_t *store, const proxy_config_t *cfg, + double net_diff, const bitcoind_template_t *t, + stratum_job_t *job) { + if (strcmp(cfg->pool_mode, "pplns-coinbase") != 0) return 0; + if (!(net_diff > 0.0)) { + LOG_WARN("pplns-coinbase: no network difficulty yet — cannot size the " + "window, holding this template back"); + return -1; + } + double window = net_diff * cfg->pplns_window_diff_multiple; + + store_window_entry_t win[COINBASE_MAX_PAYOUT_OUTPUTS]; + size_t n = 0; + double total = 0.0; + int truncated = 0; + char werr[256] = {0}; + if (store_pplns_window(store, window, win, + sizeof win / sizeof win[0], &n, &total, + &truncated, werr, sizeof werr) < 0) { + LOG_WARN("pplns-coinbase: window query failed: %s", werr); + return -1; + } + if (n == 0 || !(total > 0.0)) { + LOG_INFO("pplns-coinbase: no shares in the window yet — holding this " + "template back rather than mining a block that pays nobody"); + return -1; + } + if (truncated) { + /* store_pplns_window drops the tail from the TOTAL as well, so those + * miners' claims are redistributed to the ones that fit rather than + * carried as a debt. Say so: it is a real, if small, unfairness and + * it should not be discovered in the amounts. */ + LOG_WARN("pplns-coinbase: window holds more than %zu payable miners; " + "the smallest are not in this block's coinbase and their " + "share of it goes to the others", + (size_t)(sizeof win / sizeof win[0])); + } + + /* Split the payable amount by difficulty. The fee comes off the top the + * same way every other builder does it, so it is computed here too -- + * the payees have to sum to exactly what is left, or the builder refuses + * rather than letting the block forfeit the difference. */ + int64_t value = t->coinbase_value_sats; + int64_t fee = 0; + if (cfg->operator_address[0] && cfg->fee_bps > 0) { + int64_t f = (value * (int64_t)cfg->fee_bps) / 10000; + if (f >= 546) fee = f; /* COINBASE_DUST_SATS */ + } + int64_t payable = value - fee; + if (payable <= 0) { + LOG_WARN("pplns-coinbase: template pays %lld sats, nothing left after " + "the operator fee", (long long)value); + return -1; + } + + coinbase_payee_t payees[COINBASE_MAX_PAYOUT_OUTPUTS]; + int64_t assigned = 0; + for (size_t i = 0; i < n; ++i) { + payees[i].address = win[i].payout_address; + payees[i].sats = (int64_t)((double)payable * (win[i].difficulty / total)); + assigned += payees[i].sats; + } + /* Truncating division leaves a few sats over. They cannot be dropped -- + * the builder requires the split to spend `payable` exactly, and a + * coinbase that pays out less forfeits the difference to nobody -- so + * they go to the largest claim, which store_pplns_window returns first. + * A handful of satoshis, to the miner with the strongest claim on them. */ + if (assigned < payable) payees[0].sats += payable - assigned; + + if (stratum_job_set_window(job, payees, n) < 0) { + LOG_WARN("pplns-coinbase: could not attach the window to the job"); + return -1; + } + LOG_DEBUG("pplns-coinbase: window of %zu miner(s), %.2f difficulty, " + "paying %lld sats", n, total, (long long)payable); + return 0; +} + static stratum_job_t *build_job_from_template(const proxy_config_t *cfg, const bitcoind_template_t *t, char *errbuf, size_t errlen) { @@ -785,6 +878,19 @@ static void *tip_watcher(void *arg) { bitcoind_template_free(t); continue; } + /* pplns-coinbase pays the window out of this block's own + * coinbase, so the window has to be on the job before anyone + * mines it. A job that cannot carry one is not published: every + * coinbase rendered from it would pay nobody, which forfeits the + * whole block. */ + if (attach_pplns_window(s->store, s->cfg, + atomic_load_explicit(&s->net_difficulty, + memory_order_relaxed), + t, job) != 0) { + stratum_job_free(job); + bitcoind_template_free(t); + continue; + } stratum_server_set_job(s->srv, job, new_tip); /* Difficulty and block value move with the template, so the * rate has to move with it too. */ @@ -1166,10 +1272,15 @@ int main(int argc, char **argv) { 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; + /* Same accounting, no custody: the coinbase pays the window directly. */ + int mode_pplns_cb = strcmp(cfg.pool_mode, "pplns-coinbase") == 0; stcfg.pps_accrues = mode_pps_classic; - stcfg.coinbase_pays_pool = mode_pps_classic || mode_pplns; + /* Mutually exclusive by construction: the reward goes to the miners or + * to the pool, never both. */ + stcfg.coinbase_pays_pool = mode_pps_classic || + mode_pplns_thunder || mode_pplns_btc; + stcfg.coinbase_pays_window = mode_pplns_cb; 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); @@ -1212,7 +1323,14 @@ int main(int argc, char **argv) { } sctx.srv = srv; /* First job of the process: nobody is connected yet, so the flag reaches - * no one, but a new tip is what it describes. */ + * no one, but a new tip is what it describes. + * + * Under pplns-coinbase this one deliberately carries no window. Network + * difficulty has not been read yet, and a process that has just started + * has no shares to pay anyway — so the window would be empty even if it + * could be sized. conn_render_coinbase refuses to render from a + * windowless job rather than paying nobody, and the tip watcher publishes + * a job with a real window within one poll interval. */ stratum_server_set_job(srv, initial_job, 1); /* A port's promised floor and the chain can disagree, and the floor wins diff --git a/src/stratum.c b/src/stratum.c index f5ca7f1..3536505 100644 --- a/src/stratum.c +++ b/src/stratum.c @@ -133,6 +133,13 @@ struct stratum_job { char **tx_hex_list; /* owned */ size_t tx_count; + /* pplns-coinbase: who this job's block pays, snapshotted at build time. + * `payees[i].address` points into the arena so the whole window is two + * allocations rather than one per miner. */ + coinbase_payee_t *payees; + char *payee_addrs; + size_t n_payees; + uint64_t created_ms; /* for retention ring */ /* References held. The server holds one for current_job and one for each @@ -233,9 +240,38 @@ void stratum_job_free(stratum_job_t *j) { for (size_t i = 0; i < j->tx_count; ++i) free(j->tx_hex_list[i]); free(j->tx_hex_list); } + free(j->payees); + free(j->payee_addrs); free(j); } +/* Copy a window onto the job. Two allocations for the whole thing: one array + * of payees and one arena the addresses live in, so a 200-miner window is not + * 200 strdups that have to be unwound on every job retirement. */ +int stratum_job_set_window(stratum_job_t *j, + const coinbase_payee_t *payees, size_t n_payees) { + if (!j) return -1; + free(j->payees); j->payees = NULL; + free(j->payee_addrs); j->payee_addrs = NULL; + j->n_payees = 0; + if (!payees || n_payees == 0) return 0; + + enum { ADDR_STRIDE = 128 }; + coinbase_payee_t *arr = calloc(n_payees, sizeof *arr); + char *arena = calloc(n_payees, ADDR_STRIDE); + if (!arr || !arena) { free(arr); free(arena); return -1; } + for (size_t i = 0; i < n_payees; ++i) { + char *dst = arena + i * ADDR_STRIDE; + snprintf(dst, ADDR_STRIDE, "%s", payees[i].address ? payees[i].address : ""); + arr[i].address = dst; + arr[i].sats = payees[i].sats; + } + j->payees = arr; + j->payee_addrs = arena; + j->n_payees = n_payees; + return 0; +} + /* ============================================================ server ==== */ struct stratum_server { @@ -703,6 +739,13 @@ static cJSON *make_notify_params(const stratum_job_t *j, * * Caller must hold c->cb_lock, and must keep holding it for as long as it * reads the cb1/cb2 this leaves behind. */ +/* A pplns-coinbase job with no window pays nobody, and a coinbase that pays + * nobody forfeits the whole block. Better to render nothing and let the miner + * wait for the next template. */ +static int j_payees_missing(const stratum_job_t *job) { + return !job->payees || job->n_payees == 0; +} + static int conn_render_coinbase(stratum_server_t *s, stratum_conn_t *c, const stratum_job_t *job) { if (!c->authorized || c->payout_address[0] == '\0') return -1; @@ -712,7 +755,33 @@ 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.coinbase_pays_pool) { + if (s->cfg.coinbase_pays_window) { + /* pplns-coinbase: the block pays the window that produced it, one + * output per miner, and the pool never receives the reward. The + * window was snapshotted onto the job when the template was built — + * every connection therefore renders the SAME coinbase, exactly as + * the pooled modes do, because the outputs live in cb2 and only the + * extranonce differs per connection. */ + if (j_payees_missing(job)) { + LOG_WARN("stratum: no PPLNS window on job %s — refusing to render " + "a coinbase that would pay nobody", job->job_id); + return -1; + } + if (job->coinbasetxn_hex) { + rc = coinbase_build_window_from_template( + job->coinbasetxn_hex, job->payees, job->n_payees, + s->cfg.operator_address, s->cfg.fee_bps, + s->cfg.coinbase_tag, job->en1_size, job->en2_size, + s->cfg.max_payout_outputs, &parts, NULL, NULL, + err, sizeof err); + } else { + rc = coinbase_build_window( + job->height, job->value_sats, job->payees, job->n_payees, + s->cfg.operator_address, s->cfg.fee_bps, job->wc_hex, + s->cfg.coinbase_tag, job->en1_size, job->en2_size, + s->cfg.max_payout_outputs, &parts, NULL, err, sizeof err); + } + } else 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 diff --git a/src/stratum.h b/src/stratum.h index b27c8f2..38262f6 100644 --- a/src/stratum.h +++ b/src/stratum.h @@ -6,6 +6,8 @@ #include #include +#include "coinbase.h" /* coinbase_payee_t */ + typedef struct stratum_job stratum_job_t; /* The extranonce split, advertised on mining.subscribe and baked into every @@ -64,6 +66,20 @@ stratum_job_t *stratum_job_new( const char *const *tx_hex_list, size_t tx_count, const char *coinbasetxn_hex, int coinbase_has_witness); +/* Attach the PPLNS window this job's block would pay, for pool_mode = + * pplns-coinbase. The job takes its own copy of both the amounts and the + * addresses, so the caller's array can be stack-allocated and reused. + * + * Called once, before the job is published — the window is a SNAPSHOT taken + * when the template was built, not a live view. A miner that connects after + * the job went out is not in that job's coinbase and is not paid by a block + * found against it; it is picked up by the next template. The refresh cadence + * is what bounds how stale that snapshot gets. + * + * Returns 0 on success, negative on allocation failure. */ +int stratum_job_set_window(stratum_job_t *j, + const coinbase_payee_t *payees, size_t n_payees); + void stratum_job_free(stratum_job_t *j); /* Observer hooks filled in by main.c (typically routed to the sqlite store). */ @@ -180,6 +196,16 @@ typedef struct { int coinbase_pays_pool; int username_is_thunder; + /* pplns-coinbase: the coinbase pays the WINDOW directly, one output per + * miner, so the pool never receives the reward at all. Mutually exclusive + * with coinbase_pays_pool — the reward goes to the miners or to the pool, + * never both — and distinct from solo, which pays only the finder. + * + * The window itself rides on the job (stratum_job_set_window), because it + * is a snapshot taken when the template was built. */ + int coinbase_pays_window; + size_t max_payout_outputs; /* 0 = COINBASE_MAX_PAYOUT_OUTPUTS */ + /* 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, diff --git a/tests/test_config.c b/tests/test_config.c index 78c10ce..43c1e31 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -205,6 +205,45 @@ static void test_the_window_defaults_to_two(void) { CHECK(cfg.pplns_window_diff_multiple == 2.0); } +/* pplns-coinbase pays the window out of the block's own coinbase, so the pool + * never receives the reward. */ +static void test_pplns_coinbase_is_accepted_without_a_pool_wallet(void) { + proxy_config_t cfg; char err[256] = {0}; + char body[512]; + snprintf(body, sizeof body, + "operator_address = %s\npool_mode = pplns-coinbase\n", VALID_ADDR); + CHECK(load_text(body, &cfg, err, sizeof err) == 0); + CHECK(strcmp(cfg.pool_mode, "pplns-coinbase") == 0); + CHECK(cfg.pplns_window_diff_multiple == 2.0); +} + +/* A configured pool wallet is refused rather than ignored. The whole claim of + * this mode is that the pool never holds the reward, and a pool_btc_address is + * the shape of a pool that does — most likely a mode switched in place without + * the rest of the config following. Running a custodial-looking pool that + * quietly is not one is worse than refusing to start. */ +static void test_pplns_coinbase_refuses_a_pool_wallet(void) { + proxy_config_t cfg; char err[256] = {0}; + char body[512]; + snprintf(body, sizeof body, + "operator_address = %s\npool_mode = pplns-coinbase\n" + "pool_btc_address = %s\n", VALID_ADDR, VALID_ADDR); + CHECK(load_text(body, &cfg, err, sizeof err) != 0); + CHECK(strstr(err, "must not be set") != NULL); + CHECK(strstr(err, "pays miners directly from the coinbase") != NULL); +} + +/* It is a pplns mode, so the window knob applies to it too. */ +static void test_pplns_coinbase_validates_the_window(void) { + proxy_config_t cfg; char err[256] = {0}; + char body[512]; + snprintf(body, sizeof body, + "operator_address = %s\npool_mode = pplns-coinbase\n" + "pplns_window_diff_multiple = 0\n", VALID_ADDR); + CHECK(load_text(body, &cfg, err, sizeof err) != 0); + CHECK(strstr(err, "pplns_window_diff_multiple") != NULL); +} + /* ---- listener lines ------------------------------------------------------ * * A `listener` line is how rented hashrate is served its own difficulty. A @@ -295,6 +334,9 @@ int main(void) { test_quoted_value_keeps_hash(); test_inline_comment_still_strips(); test_rejects_bad_operator_address(); + test_pplns_coinbase_validates_the_window(); + test_pplns_coinbase_refuses_a_pool_wallet(); + test_pplns_coinbase_is_accepted_without_a_pool_wallet(); test_a_nonsense_log_level_warns_and_keeps_the_default(); test_log_level_accepts_names_and_numbers(); test_a_listener_without_a_port_is_refused(); diff --git a/tests/test_stratum.c b/tests/test_stratum.c index 42823a3..83d3097 100644 --- a/tests/test_stratum.c +++ b/tests/test_stratum.c @@ -120,6 +120,8 @@ static int count_lines(const char *buf, size_t len) { /* Standard regtest P2WPKH used in fixtures so the per-connection coinbase * renderer can produce a valid scriptPubKey. */ #define TEST_ADDR "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080" +/* A second, distinct regtest address, so a window can have two payees. */ +#define TEST_ADDR2 "bcrt1qzyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3lgth6c" /* Build a tiny job for tests. The coinbase is rendered per-connection at * notify/submit time using the miner's address, so the job only carries @@ -2709,6 +2711,99 @@ static void test_suggest_difficulty_before_authorize(void) { printf("ok: suggest_difficulty before authorize survives to the first job\n"); } +/* ---------------------------------------------------------------------- */ +/* pplns-coinbase: the block pays the window, and the pool holds nothing */ +/* ---------------------------------------------------------------------- */ + +/* The window rides on the JOB, not the connection, because it is a snapshot + * taken when the template was built. Every connection therefore renders the + * same coinbase -- the outputs live in cb2 and only the extranonce differs -- + * which is the same shape the pooled modes already have. */ +static stratum_server_t *cbwin_server(stratum_cfg_t *cfg, obs_t *obs) { + *cfg = (stratum_cfg_t){ .bind_port = 0, .max_conns = 2, .initial_diff = 1.0, + .coinbase_pays_window = 1, + .username_is_thunder = 0, + .pps_accrues = 0, + .ctx = obs, .on_share = on_share, + .on_reject = on_reject, .on_block = on_block }; + snprintf(cfg->bind_addr, sizeof cfg->bind_addr, "127.0.0.1"); + snprintf(cfg->operator_address, sizeof cfg->operator_address, "%s", TEST_ADDR); + stratum_server_t *s = NULL; + stratum_server_start(cfg, &s); + return s; +} + +/* Count outputs in the rendered coinbase, reading the transaction rather than + * trusting anything the builder reported. */ +static uint64_t cbwin_output_count(stratum_server_t *s, stratum_conn_t *c, + const char *job_id) { + const uint8_t *cb1 = NULL, *cb2 = NULL, *en1 = NULL; + size_t cb1_len = 0, cb2_len = 0; + if (stratum_conn_coinbase_for_test(s, c, job_id, &cb1, &cb1_len, + &cb2, &cb2_len, &en1) != 0) return 0; + /* cb2 = sequence(4) | varint n_outputs | ... */ + return cb2_len > 4 ? cb2[4] : 0; /* every case here is < 253 outputs */ +} + +static void test_pplns_coinbase_pays_every_miner_in_the_window(void) { + obs_t obs = {0}; + stratum_cfg_t cfg; + stratum_server_t *s = cbwin_server(&cfg, &obs); + CHECK(s != NULL); if (!s) return; + + uint8_t net[32]; memset(net, 0xff, 32); + stratum_job_t *job = make_test_job("JW", net); + /* 50 BTC, no fee configured on the job's side: the two payees sum to the + * whole value, which is what the builder requires. */ + const coinbase_payee_t win[] = { + { TEST_ADDR, 3000000000LL }, + { TEST_ADDR2, 2000000000LL }, + }; + CHECK(stratum_job_set_window(job, win, 2) == 0); + stratum_server_set_job(s, job, 1); + + stratum_conn_t *c = stratum_conn_new_for_test(s); + handshake(s, c); + /* Exactly two outputs: one per miner in the window, and nothing else. + * fee_bps is 0 here, so there is not even an operator output — which is + * the mode stated at its plainest. Every satoshi of the block leaves to + * the miners, and no address the pool controls appears at all. */ + uint64_t n = cbwin_output_count(s, c, "JW"); + CHECK(n == 2); + + /* And it is the same coinbase for a second connection: the window is a + * property of the job, not of who is asking. */ + stratum_conn_t *c2 = stratum_conn_new_for_test(s); + handshake(s, c2); + CHECK(cbwin_output_count(s, c2, "JW") == n); + + stratum_conn_free_for_test(c); + stratum_conn_free_for_test(c2); + stratum_server_free(s); +} + +/* A job with no window pays nobody, and a coinbase that pays nobody forfeits + * the entire block. Rendering nothing and making the miner wait for the next + * template is the only safe answer. */ +static void test_a_windowless_job_renders_no_coinbase(void) { + obs_t obs = {0}; + stratum_cfg_t cfg; + stratum_server_t *s = cbwin_server(&cfg, &obs); + CHECK(s != NULL); if (!s) return; + + uint8_t net[32]; memset(net, 0xff, 32); + stratum_server_set_job(s, make_test_job("JNW", net), 1); /* no window */ + + stratum_conn_t *c = stratum_conn_new_for_test(s); + handshake(s, c); + const uint8_t *cb1 = NULL, *cb2 = NULL, *en1 = NULL; + size_t cb1_len = 0, cb2_len = 0; + CHECK(stratum_conn_coinbase_for_test(s, c, "JNW", &cb1, &cb1_len, + &cb2, &cb2_len, &en1) != 0); + stratum_conn_free_for_test(c); + stratum_server_free(s); +} + int main(void) { test_password_diff_raises(); test_password_diff_never_lowers(); @@ -2750,6 +2845,8 @@ int main(void) { test_gated_pps_refuses_authorize_and_submits(); test_gate_can_be_disabled(); test_solo_is_never_gated(); + test_a_windowless_job_renders_no_coinbase(); + test_pplns_coinbase_pays_every_miner_in_the_window(); test_pplns_btc_takes_a_bitcoin_username(); test_pplns_thunder_takes_a_thunder_username(); test_pplns_is_never_gated(); From e7ed44e7a262c0ea3791fe4014f80794689ecc1e Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 8 Sep 2026 09:59:35 +0200 Subject: [PATCH 05/36] Coinbase-direct PPLNS: prove it on chain, and fix the deadlock it found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The e2e found that a brand-new pplns-coinbase pool could never start. WARN stratum: no PPLNS window on job … — refusing to render a coinbase that would pay nobody INFO pplns-coinbase: no shares in the window yet — holding this template back rather than mining a block that pays nobody INFO pplns-coinbase: no shares in the window yet — holding this template back rather than mining a block that pays nobody No shares means no window, no window means no coinbase, no coinbase means no miner can work, and no work means no shares. Forever. Every unit test passed throughout: each half of that loop is correct on its own, and only running it shows they close. The fix is not a special case so much as noticing what PPLNS over an empty window degenerates to. With no prior work, the only party with a claim on the block is whoever finds it — which is solo. So a windowless job renders the solo shape, per connection, and the first accepted share ends it permanently. Both the empty-window template path and the first job of a process now say so in the log. An operator watching the first block of a new pool go entirely to its finder deserves to know that was deliberate rather than the window silently failing. The e2e proves what only the chain can: - the pool starts and mines with NO pool_btc_address configured - the coinbase pays the miner 4950000000 sats and the operator 50000000, read out of the block rather than out of anything simplepool wrote - no output pays a third address. There is no pool wallet in this mode, so any other address would be the pool holding the reward — the one claim a bookkeeping bug cannot fake - pps_credits stays EMPTY. A balance there would mean the pool believes it owes money it has already paid on chain - and it mines a SECOND block to prove the mode rather than only the bootstrap, asserting the log shows a template built from a real window That last stage exists because of a mistake worth recording: the first version asserted the tip watcher had logged an empty window, which passed once and then failed. On a regtest chain the first block is found about a second after startup, before the first tip change — so whether the watcher ever SEES an empty window is a race, and the bootstrap actually happened via the initial job, which logged nothing at all. The assertion now keys on the initial job's own message, which is deterministic, and the second block is what proves the window path. CI runs it after the other two pplns suites. --- .github/workflows/integration_tests.yaml | 10 + .gitignore | 1 + src/main.c | 30 ++- src/stratum.c | 30 ++- tests/test_pplns_coinbase_regtest.sh | 301 +++++++++++++++++++++++ tests/test_stratum.c | 23 +- 6 files changed, 383 insertions(+), 12 deletions(-) create mode 100755 tests/test_pplns_coinbase_regtest.sh diff --git a/.github/workflows/integration_tests.yaml b/.github/workflows/integration_tests.yaml index 2e6a9d2..b1fa58b 100644 --- a/.github/workflows/integration_tests.yaml +++ b/.github/workflows/integration_tests.yaml @@ -56,6 +56,13 @@ jobs: - name: Run PPLNS end-to-end regtest test run: bash tests/test_pplns_regtest.sh + # The coinbase-direct rail. Distinct from the two above because it has + # no ledger step at all -- the payment IS the block -- so what it proves + # is on-chain: the coinbase pays the window, and no output pays anything + # the pool controls beyond its fee. + - name: Run coinbase-direct PPLNS end-to-end regtest test + run: bash tests/test_pplns_coinbase_regtest.sh + - name: Upload logs if: failure() uses: actions/upload-artifact@v4 @@ -64,11 +71,14 @@ jobs: path: | .regtest-e2e/logs/ .regtest-pplns/logs/ + .regtest-cbwin/logs/ /tmp/simplepool-e2e.log /tmp/simplepool-e2e.conf /tmp/simplepool-int.log /tmp/simplepool-pplns-*.log /tmp/simplepool-pplns-*.conf + /tmp/simplepool-cbwin.log + /tmp/simplepool-cbwin.conf retention-days: 14 if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 957b9fe..2ca5966 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /.regtest-payout/ /.regtest-pplns/ /.regtest-btcpay/ +/.regtest-cbwin/ /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 15360be..96e24ab 100644 --- a/src/main.c +++ b/src/main.c @@ -220,9 +220,23 @@ static int attach_pplns_window(store_t *store, const proxy_config_t *cfg, return -1; } if (n == 0 || !(total > 0.0)) { - LOG_INFO("pplns-coinbase: no shares in the window yet — holding this " - "template back rather than mining a block that pays nobody"); - return -1; + /* Bootstrap. A pool that has never been mined has no shares, so it + * has no window, so it cannot build a coinbase — and if that stopped + * it publishing a job, no miner could ever submit the share that + * would populate the window. A brand-new pool would never start. + * + * The job goes out with no window attached and the renderer falls + * back to paying whoever is connected, per connection, exactly as + * solo does. That is not a special case so much as what PPLNS over an + * empty window degenerates to: with no prior work, the only party + * with a claim on the block is whoever finds it. + * + * Self-correcting, and only ever true once — the first accepted share + * populates the window, and every job after it carries one. */ + LOG_INFO("pplns-coinbase: no shares yet, so no window; this template " + "pays whoever finds it, as solo would. The first accepted " + "share ends this."); + return 0; } if (truncated) { /* store_pplns_window drops the tail from the TOTAL as well, so those @@ -1331,6 +1345,16 @@ int main(int argc, char **argv) { * could be sized. conn_render_coinbase refuses to render from a * windowless job rather than paying nobody, and the tip watcher publishes * a job with a real window within one poll interval. */ + if (strcmp(cfg.pool_mode, "pplns-coinbase") == 0) { + /* Said out loud because it is otherwise invisible: this job renders a + * solo-shaped coinbase, and an operator watching the first block of a + * new pool get paid entirely to its finder deserves to know that was + * deliberate rather than the window silently failing. */ + LOG_INFO("pplns-coinbase: the first job of a process carries no " + "window — network difficulty is unread and a fresh pool has " + "no shares — so it pays whoever finds it, as solo would. " + "Every job after the first accepted share carries a window."); + } stratum_server_set_job(srv, initial_job, 1); /* A port's promised floor and the chain can disagree, and the floor wins diff --git a/src/stratum.c b/src/stratum.c index 3536505..d752e4e 100644 --- a/src/stratum.c +++ b/src/stratum.c @@ -763,9 +763,14 @@ static int conn_render_coinbase(stratum_server_t *s, stratum_conn_t *c, * the pooled modes do, because the outputs live in cb2 and only the * extranonce differs per connection. */ if (j_payees_missing(job)) { - LOG_WARN("stratum: no PPLNS window on job %s — refusing to render " - "a coinbase that would pay nobody", job->job_id); - return -1; + /* Bootstrap: no shares have been accepted yet, so there is no + * window to pay. Fall through to the solo shape — this + * connection's own coinbase, paying this miner. See + * attach_pplns_window() in main.c: with no prior work the only + * party with a claim on the block is whoever finds it, and + * refusing to render here instead would deadlock a new pool + * forever (no coinbase, so no shares, so no window). */ + goto render_solo; } if (job->coinbasetxn_hex) { rc = coinbase_build_window_from_template( @@ -802,6 +807,25 @@ static int conn_render_coinbase(stratum_server_t *s, stratum_conn_t *c, job->en1_size, job->en2_size, &parts, NULL, NULL, err, sizeof err); } + } else if (0) { +render_solo: + /* Reached either by solo mode or by a pplns-coinbase job that has no + * window yet. Both pay this one connection's miner. */ + if (job->coinbasetxn_hex) { + rc = coinbase_build_from_template(job->coinbasetxn_hex, + c->payout_address, + s->cfg.operator_address, s->cfg.fee_bps, + s->cfg.coinbase_tag, + job->en1_size, job->en2_size, + &parts, NULL, NULL, NULL, err, sizeof err); + } else { + rc = coinbase_build_split(job->height, job->value_sats, + c->payout_address, + s->cfg.operator_address, s->cfg.fee_bps, + job->wc_hex, s->cfg.coinbase_tag, + job->en1_size, job->en2_size, + &parts, NULL, NULL, err, sizeof err); + } } else if (job->coinbasetxn_hex) { /* Backend dictated the coinbase (e.g. CUSF enforcer): build from it, * redirecting the reward output to this miner and preserving the diff --git a/tests/test_pplns_coinbase_regtest.sh b/tests/test_pplns_coinbase_regtest.sh new file mode 100755 index 0000000..045b3f6 --- /dev/null +++ b/tests/test_pplns_coinbase_regtest.sh @@ -0,0 +1,301 @@ +#!/usr/bin/env bash +# End-to-end test of pool_mode = pplns-coinbase: the window paid straight out +# of the coinbase of the block that produced it. +# +# bitcoind-patched <-ZMQ/RPC- bip300301_enforcer (walletless) +# ^ | GBT +# | submitblock v +# +----------------------- simplepool (pplns-coinbase) +# ^ stratum +# | +# cpuminer.js +# +# The other two pplns rails credit pps_credits and hand the money to a payout +# worker ~100 blocks later. This one has no ledger step at all: the payment IS +# the block. So what has to be proved is different, and only the chain can +# prove it — +# +# 1. the pool starts and mines with NO pool_btc_address configured. There is +# no pool wallet in this mode; if one were needed the config would have +# refused it, and if the coinbase quietly paid one anyway the assertions +# below would find it. +# 2. the mined block's coinbase pays the miners in the window, read out of +# the chain rather than out of anything simplepool wrote. +# 3. NO output pays an address the pool controls. That is the whole claim of +# the mode and it is the one thing a bookkeeping bug cannot fake. +# 4. pps_credits stays empty. Nothing accrues off-chain because nothing is +# owed off-chain — a balance here would mean the pool thinks it owes +# money it already paid on-chain. +# +# Env: +# REGTEST_DIR data dir, WIPED each run (default: /.regtest-cbwin) +# 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-cbwin}" +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" +POOL_CONF="/tmp/simplepool-cbwin.conf" +POOL_LOG="/tmp/simplepool-cbwin.log" +POOL_DB="/tmp/simplepool-cbwin.db" + +# The operator's fee address. Deliberately the ONLY address the pool controls +# in this test, so an assertion that no pool-controlled output exists beyond +# the fee is meaningful. +OPERATOR_ADDR="bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080" +# What the miner authorizes with, and therefore what the coinbase must pay. +MINER_ADDR="bcrt1qzyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3lgth6c" +POOL_PID="" + +cli() { "$BIN/bitcoin-cli" -datadir="$REGTEST_DIR/data/bitcoind" -regtest \ + -rpcuser=user -rpcpassword=password "$@"; } +stage() { echo; echo "=== cbwin-e2e: $1"; } + +dump_logs() { + echo "!!! cbwin-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 " 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 python3; 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" + +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" +# The coinbase is classic-shaped, 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 around the window — +# mining against a template without them would exercise an easier case than +# production ever runs. +"$ROOT/scripts/regtest/activate-thunder.sh" + +stage "start simplepool in pplns-coinbase mode" +# Note what is NOT here: pool_btc_address. There is no pool wallet in this +# mode, and the config refuses one. +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 "mine one block through stratum as ${MINER_ADDR}" +TIP_BEFORE=$(cli getblockcount) +node "$ROOT/scripts/regtest/cpuminer.js" --port "$POOL_PORT" --user "$MINER_ADDR" --timeout 180 +TIP_AFTER=$(cli getblockcount) +echo " height: $TIP_BEFORE -> $TIP_AFTER" +[ "$TIP_AFTER" -gt "$TIP_BEFORE" ] || { + echo "FAIL: block submitted but the chain did not advance" >&2; exit 1; } + +stage "assert the coinbase paid the window, on chain" +TIP="$(cli getbestblockhash)" +CB_TXID="$(cli getblock "$TIP" 2 | jq -r '.tx[0].txid')" +CB_JSON="$(cli getblock "$TIP" 2 | jq -c '.tx[0]')" +echo " block $TIP coinbase $CB_TXID" +CB_JSON="$CB_JSON" MINER_ADDR="$MINER_ADDR" OPERATOR_ADDR="$OPERATOR_ADDR" python3 - <<'PY' +import json, os, sys + +cb = json.loads(os.environ['CB_JSON']) +miner = os.environ['MINER_ADDR'] +op = os.environ['OPERATOR_ADDR'] + +paid = {} +op_returns = 0 +for o in cb['vout']: + spk = o['scriptPubKey'] + if spk.get('type') == 'nulldata': + op_returns += 1 + continue + addr = spk.get('address') + if addr is None: + print(f"FAIL: spendable output with no address: {spk.get('hex')}", file=sys.stderr) + sys.exit(1) + paid[addr] = paid.get(addr, 0) + round(o['value'] * 1e8) + +print(f" spendable outputs: {len(paid)} op_returns(commitments): {op_returns}") +for a, v in sorted(paid.items(), key=lambda kv: -kv[1]): + who = 'MINER' if a == miner else ('operator fee' if a == op else 'UNKNOWN') + print(f" {v:>14} sats -> {a} ({who})") + +# 1. the miner in the window is paid, directly, in this block +if miner not in paid: + print(f"FAIL: the window's miner {miner} has no coinbase output", file=sys.stderr) + sys.exit(1) + +# 2. nothing is paid to an address that is neither the window nor the fee. +# There is no pool wallet in this mode, so any third address is one. +unknown = [a for a in paid if a not in (miner, op)] +if unknown: + print(f"FAIL: coinbase pays {unknown}, which is neither the window nor " + f"the operator fee — the pool is holding the reward", file=sys.stderr) + sys.exit(1) + +# 3. the miner gets the bulk of it. fee_bps is 100, so the operator should +# hold ~1% and the miner ~99% — a split the other way round would mean the +# fee and the payout had been swapped. +mine_sats = paid[miner] +op_sats = paid.get(op, 0) +total = mine_sats + op_sats +if mine_sats <= op_sats: + print(f"FAIL: miner got {mine_sats} and the operator {op_sats}", file=sys.stderr) + sys.exit(1) +share = op_sats / total if total else 0 +if share > 0.02: + print(f"FAIL: operator holds {share:.3%} of the block, expected ~1%", + file=sys.stderr) + sys.exit(1) +print(f" miner {mine_sats} sats, operator {op_sats} sats ({share:.2%})") +PY + +stage "assert the FIRST block took the bootstrap path" +# Worth pinning explicitly, because it is the path that used to deadlock: a +# pool with no shares has no window, and refusing to render there meant no +# coinbase, so no share, so no window, forever. +# Deterministic, unlike the tip-watcher's own empty-window message: on a fast +# chain the first block can be found before the first tip change, so whether +# the watcher ever SEES an empty window is a race. The initial job always +# carries none, and always says so. +grep -q "the first job of a process carries no window" "$POOL_LOG" || { + echo "FAIL: expected the first job to be announced as windowless" >&2 + exit 1; } +echo " bootstrap path announced, as it must be on a pool with no shares" + +stage "mine a SECOND block, now that a window exists" +# The first block proved bootstrap. This one proves the mode: shares exist +# now, so the job carries a real window and the coinbase is built from it +# rather than from the connection. +TIP_BEFORE2=$(cli getblockcount) +node "$ROOT/scripts/regtest/cpuminer.js" --port "$POOL_PORT" --user "$MINER_ADDR" --timeout 180 +TIP_AFTER2=$(cli getblockcount) +echo " height: $TIP_BEFORE2 -> $TIP_AFTER2" +[ "$TIP_AFTER2" -gt "$TIP_BEFORE2" ] || { + echo "FAIL: second block was not mined" >&2; exit 1; } + +grep -q "pplns-coinbase: window of" "$POOL_LOG" || { + echo "FAIL: no template was ever built from a real window — every block" >&2 + echo " took the bootstrap path, so the mode itself is unproven" >&2 + exit 1; } +echo " window path taken: $(grep -o 'window of [0-9]* miner(s), [0-9.]* difficulty' "$POOL_LOG" | tail -1)" + +stage "assert the second block's coinbase also paid the miner" +TIP2="$(cli getbestblockhash)" +CB_JSON2="$(cli getblock "$TIP2" 2 | jq -c '.tx[0]')" +CB_JSON="$CB_JSON2" MINER_ADDR="$MINER_ADDR" OPERATOR_ADDR="$OPERATOR_ADDR" python3 - <<'PY' +import json, os, sys +cb = json.loads(os.environ['CB_JSON']) +miner, op = os.environ['MINER_ADDR'], os.environ['OPERATOR_ADDR'] +paid = {} +for o in cb['vout']: + spk = o['scriptPubKey'] + if spk.get('type') == 'nulldata': + continue + paid[spk['address']] = paid.get(spk['address'], 0) + round(o['value'] * 1e8) +if miner not in paid: + print(f"FAIL: window-built coinbase does not pay {miner}", file=sys.stderr) + sys.exit(1) +unknown = [a for a in paid if a not in (miner, op)] +if unknown: + print(f"FAIL: window-built coinbase pays {unknown}", file=sys.stderr) + sys.exit(1) +print(f" miner {paid[miner]} sats, operator {paid.get(op, 0)} sats") +PY + +stage "assert nothing accrued off-chain" +# The payment was the block. A pps_credits row here would mean the pool +# believes it owes money it has already paid on-chain — the double-payment +# this mode exists to make impossible. +CREDITS="$(sqlite3 "$POOL_DB" "SELECT COALESCE(SUM(accrued_sats),0) FROM pps_credits")" +ROWS="$(sqlite3 "$POOL_DB" "SELECT COUNT(*) FROM pps_credits")" +echo " pps_credits rows=$ROWS accrued=$CREDITS" +[ "$ROWS" = "0" ] && [ "$CREDITS" = "0" ] || { + echo "FAIL: pplns-coinbase accrued $CREDITS sats off-chain across $ROWS row(s);" >&2 + echo " the coinbase already paid the miners" >&2 + exit 1; } + +stage "assert the block was recorded, and needs no distribution" +BLK_ROWS="$(sqlite3 "$POOL_DB" "SELECT COUNT(*) FROM blocks_found")" +echo " blocks_found rows=$BLK_ROWS" +[ "$BLK_ROWS" -ge 1 ] || { echo "FAIL: the block was not recorded" >&2; exit 1; } + +echo +echo "cbwin-e2e: PASS (the window was paid from the block's own coinbase," +echo " and the pool never held the reward)" diff --git a/tests/test_stratum.c b/tests/test_stratum.c index 83d3097..a879c3e 100644 --- a/tests/test_stratum.c +++ b/tests/test_stratum.c @@ -2782,10 +2782,17 @@ static void test_pplns_coinbase_pays_every_miner_in_the_window(void) { stratum_server_free(s); } -/* A job with no window pays nobody, and a coinbase that pays nobody forfeits - * the entire block. Rendering nothing and making the miner wait for the next - * template is the only safe answer. */ -static void test_a_windowless_job_renders_no_coinbase(void) { +/* Bootstrap. A pool that has never been mined has no shares, so no window — + * and refusing to render there would deadlock it forever: no coinbase means + * no miner can work, which means no share, which means no window. + * + * A regtest run found exactly that: "no shares in the window yet — holding + * this template back", repeating until the miner timed out. A windowless job + * therefore pays whoever is connected, per connection, as solo does. That is + * not a special case so much as what PPLNS over an empty window degenerates + * to: with no prior work, the only claim on the block belongs to its finder. + */ +static void test_a_windowless_job_pays_the_finder(void) { obs_t obs = {0}; stratum_cfg_t cfg; stratum_server_t *s = cbwin_server(&cfg, &obs); @@ -2796,10 +2803,14 @@ static void test_a_windowless_job_renders_no_coinbase(void) { stratum_conn_t *c = stratum_conn_new_for_test(s); handshake(s, c); + /* It renders, rather than refusing... */ const uint8_t *cb1 = NULL, *cb2 = NULL, *en1 = NULL; size_t cb1_len = 0, cb2_len = 0; CHECK(stratum_conn_coinbase_for_test(s, c, "JNW", &cb1, &cb1_len, - &cb2, &cb2_len, &en1) != 0); + &cb2, &cb2_len, &en1) == 0); + /* ...and pays exactly one miner, this connection's own, with no fee + * output because fee_bps is 0 here. */ + CHECK(cbwin_output_count(s, c, "JNW") == 1); stratum_conn_free_for_test(c); stratum_server_free(s); } @@ -2845,7 +2856,7 @@ int main(void) { test_gated_pps_refuses_authorize_and_submits(); test_gate_can_be_disabled(); test_solo_is_never_gated(); - test_a_windowless_job_renders_no_coinbase(); + test_a_windowless_job_pays_the_finder(); test_pplns_coinbase_pays_every_miner_in_the_window(); test_pplns_btc_takes_a_bitcoin_username(); test_pplns_thunder_takes_a_thunder_username(); From b817cfdf6c9122284e69a36e5eae5e71228c4250 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 8 Sep 2026 10:56:59 +0200 Subject: [PATCH 06/36] =?UTF-8?q?Budget=20the=20coinbase=20in=20bytes,=20n?= =?UTF-8?q?ot=20outputs=20=E2=80=94=20the=20count=20cap=20was=20wrong?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wired4ncer, who has run a coinbase-direct PPLNS pool on ECX alpha since 2026-08-19, gave numbers on #61 that show the cap I built measures the wrong thing: - up to 16 miners paid per block - whole coinbases of 721-817 bytes - and the binding term is NOT the payouts. The same 16 payouts cost 817 bytes against four drivechain OP_RETURNs and 769 against three. A cap counted in outputs cannot express that, because the commitments are not outputs it counts. Worse, the number was badly wrong in the same direction: 200 outputs at ~31 bytes each is over 6000 bytes of payouts alone, roughly eight times what a marketplace is observed to accept. A pool trusting that default would have had jobs refused. So the limit is now the whole serialized coinbase in bytes. Everything that is not a payout is charged first — the transaction envelope, the scriptSig, the operator output, and the commitment OP_RETURNs the template already carries — and the payouts get what is left. The commitments are simply part of what has been spent, which is exactly the relationship the count could not see. A test pins it as a relationship rather than against someone else's absolute numbers, which would be asserting on another implementation: the same window and the same budget, built from an enforcer template versus from scratch, pays strictly fewer miners from the template. Measured at a 300-byte budget: 5 against 6. A second test builds across a range of budgets and asserts the bytes on the wire never exceed the number, because a budget is only worth having if it is true of the transaction and not just of the accounting. Two details the byte accounting forced, both of which a per-output figure would have got wrong: Cost is computed per address, after resolving it. A P2TR payout is 43 bytes against a P2WPKH one's 31, so budgeting at a flat rate lets more through than fits. A payee that does not fit does not stop the loop. A later, cheaper address may still fit, and stopping early would carry money that could have been paid. coinbase_max_bytes is configurable, as he asked, because the number belongs to whichever marketplace an operator sells to rather than to us. Default 1000, documented in proxy.conf.example with where the figure comes from. A value too small to hold a coinbase and one payout is refused: that is a pool that cannot run, not one that runs badly. The remaining item from his scope is the carry-forward ledger. carry_sats is computed and reported but still nothing consumes it — today the dust a miner is owed rides on the operator output and is not recorded as owed to anyone. He reports 62 of 100 addresses sitting below the floor, so it is not a corner. --- proxy.conf.example | 21 +++++++++ src/coinbase.c | 106 +++++++++++++++++++++++++++++++++++------- src/coinbase.h | 47 +++++++++++++------ src/config.c | 16 +++++++ src/config.h | 10 ++++ src/main.c | 1 + src/stratum.c | 4 +- src/stratum.h | 2 +- tests/test_coinbase.c | 103 +++++++++++++++++++++++++++++++++++++++- tests/test_config.c | 29 ++++++++++++ 10 files changed, 304 insertions(+), 35 deletions(-) diff --git a/proxy.conf.example b/proxy.conf.example index f056346..fd417ab 100644 --- a/proxy.conf.example +++ b/proxy.conf.example @@ -211,6 +211,27 @@ pool_mode = solo # proxy warns if you set it there. # pplns_window_diff_multiple = 2.0 +# pplns-coinbase — the byte budget for the WHOLE serialized coinbase, which is +# what actually limits how many miners one block can pay. +# +# Not a consensus limit. Consensus bounds the coinbase by block weight and +# would allow far more; the real constraint is that rented-hashrate +# marketplaces verify the coinbase and refuse a job whose coinbase they +# consider oversized, and a delisting costs more than paying a few small +# miners a block later. +# +# Counted in bytes rather than in outputs because outputs are not the only +# thing spending them. On a drivechain pool the binding term is the BIP300/301 +# commitment OP_RETURNs the enforcer's template already carries: a +# coinbase-direct pool in production reports the same 16 payouts costing 817 +# bytes against four of them and 769 against three. A cap counted in outputs +# cannot see that; this can, because the commitments are simply part of what +# has already been spent. +# +# Whatever does not fit is not lost — it carries, and the smallest claims are +# the ones that wait. +# coinbase_max_bytes = 1000 + # 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/coinbase.c b/src/coinbase.c index c28147a..bb3dc7a 100644 --- a/src/coinbase.c +++ b/src/coinbase.c @@ -739,10 +739,17 @@ typedef struct { * and the reward is only known once the template has been parsed -- so the * caller cannot compute it up front, and the parser should not have to know * whether it is paying one miner or a whole window. */ -typedef int (*cb_repl_fn)(void *ctx, int64_t reward_sats, +typedef int (*cb_repl_fn)(void *ctx, int64_t reward_sats, size_t fixed_bytes, cb_repl_out_t *out, size_t cap, size_t *out_n, char *errbuf, size_t errlen); +/* Serialized size of one output: value + the scriptPubKey's length prefix + + * the script itself. */ +static size_t out_ser_size(size_t spk_len) { + size_t vi = spk_len < 253 ? 1 : (spk_len <= 0xffff ? 3 : 5); + return 8 + vi + spk_len; +} + /* Payees plus the operator. */ #define CB_MAX_REPL_OUTS (COINBASE_MAX_PAYOUT_OUTPUTS + 1) @@ -758,7 +765,7 @@ typedef int (*cb_repl_fn)(void *ctx, int64_t reward_sats, static int resolve_window_outputs(int64_t value_sats, const coinbase_payee_t *payees, size_t n_payees, const char *operator_address, int fee_bps, - size_t max_payout_outputs, + size_t max_coinbase_bytes, size_t fixed_bytes, cb_repl_out_t *out, size_t cap, size_t *out_n, coinbase_window_result_t *res, char *errbuf, size_t errlen) { @@ -774,8 +781,13 @@ static int resolve_window_outputs(int64_t value_sats, set_err(errbuf, errlen, "value_sats must be positive"); return -1; } - if (max_payout_outputs == 0) max_payout_outputs = COINBASE_MAX_PAYOUT_OUTPUTS; - if (max_payout_outputs > cap - 1) max_payout_outputs = cap - 1; + if (max_coinbase_bytes == 0) max_coinbase_bytes = COINBASE_DEFAULT_MAX_BYTES; + /* What is left for payouts once everything that is not a payout has been + * paid for: the transaction envelope, the scriptSig, the operator output + * and — the term that actually binds on a drivechain pool — the + * commitment OP_RETURNs the template already carries. */ + size_t payout_budget = max_coinbase_bytes > fixed_bytes + ? max_coinbase_bytes - fixed_bytes : 0; int64_t fee_sats = 0; cb_repl_out_t op; @@ -821,20 +833,32 @@ static int resolve_window_outputs(int64_t value_sats, qsort(rank, n_payees, sizeof *rank, payee_rank_cmp); size_t n = 0; + size_t payout_bytes = 0; int64_t carry = 0; for (size_t k = 0; k < n_payees; ++k) { const coinbase_payee_t *pe = &payees[rank[k].idx]; if (pe->sats < COINBASE_DUST_SATS) { r.dropped_dust++; carry += pe->sats; continue; } - if (n >= max_payout_outputs) { + if (n + 1 >= cap) { /* storage, not policy */ r.dropped_capped++; carry += pe->sats; continue; } + /* Resolve first: an output's cost depends on its address type, and a + * P2TR payout is 43 bytes against a P2WPKH one's 31. Budgeting at a + * fixed per-output figure would let more through than actually fit. */ if (coinbase_address_to_script(pe->address, out[n].spk, sizeof out[n].spk, &out[n].spk_len, errbuf, errlen) < 0) { free(rank); return -1; } + size_t cost = out_ser_size(out[n].spk_len); + if (payout_bytes + cost > payout_budget) { + /* No room. Keep going rather than breaking: a later payee may be + * a cheaper address type and still fit, and dropping it would + * carry money that could have been paid. */ + r.dropped_capped++; carry += pe->sats; continue; + } + payout_bytes += cost; out[n].sats = pe->sats; r.paid_sats += pe->sats; n++; r.paid_count++; @@ -843,7 +867,10 @@ static int resolve_window_outputs(int64_t value_sats, if (r.paid_count == 0) { set_err(errbuf, errlen, - "no payee in the window clears the %d-sat dust limit", + "no payee fits: %zu-byte coinbase budget leaves %zu bytes for " + "payouts after %zu bytes of transaction and commitments, and " + "nothing clears the %d-sat dust limit", + max_coinbase_bytes, payout_budget, fixed_bytes, COINBASE_DUST_SATS); return -1; } @@ -881,7 +908,7 @@ int coinbase_build_window(uint32_t height, int64_t value_sats, const char *witness_commitment_hex, const char *coinbase_tag, size_t extranonce1_size, size_t extranonce2_size, - size_t max_payout_outputs, + size_t max_coinbase_bytes, coinbase_parts_t *out, coinbase_window_result_t *res, char *errbuf, size_t errlen) { @@ -890,12 +917,33 @@ int coinbase_build_window(uint32_t height, int64_t value_sats, out->cb1 = NULL; out->cb1_len = 0; out->cb2 = NULL; out->cb2_len = 0; + /* Everything the coinbase costs before a single miner is paid. The byte + * budget is the whole transaction, so the payouts get what is left. */ + size_t wc_probe_len = 0; + if (witness_commitment_hex && *witness_commitment_hex) + wc_probe_len = strlen(witness_commitment_hex) / 2; + size_t tag_len_probe = 0; + if (coinbase_tag && *coinbase_tag) { + size_t t = strlen(coinbase_tag); + tag_len_probe = (t > 75 ? 75 : t) + 1; + } + uint8_t hp_probe[8]; + size_t ss_probe = bip34_height_push(height, hp_probe) + tag_len_probe + + extranonce1_size + extranonce2_size; + size_t fixed = 4 + 1 + 36 + (ss_probe < 253 ? 1 : 3) + ss_probe + + 4 + 3 /* output-count varint, conservatively */ + 4; + if (wc_probe_len) fixed += out_ser_size(wc_probe_len); + /* Reserve the operator output whether or not it turns out to be needed: + * carry lands on it, and carry is exactly what happens when the budget + * bites. Conservative by ~31 bytes in the rare case it is absent. */ + if (operator_address && operator_address[0]) fixed += out_ser_size(34); + /* Same resolver the template builder uses: the split is one rule, in one * place, whatever kind of coinbase it ends up in. */ cb_repl_out_t repl[CB_MAX_REPL_OUTS]; size_t n_repl = 0; if (resolve_window_outputs(value_sats, payees, n_payees, operator_address, - fee_bps, max_payout_outputs, repl, + fee_bps, max_coinbase_bytes, fixed, repl, CB_MAX_REPL_OUTS, &n_repl, res, errbuf, errlen) < 0) { return -1; @@ -1096,10 +1144,29 @@ static int build_from_template_impl(const char *coinbase_tx_hex, } int64_t reward = (int64_t)outs[reward_idx].value; + /* What this coinbase costs before any miner is paid, so the resolver can + * spend what is left. On a drivechain pool the dominant term here is the + * commitment OP_RETURNs the enforcer put in the template: they are why + * the same 16 payouts can fit under one budget and not another, and why + * a cap counted in outputs cannot express the limit at all. */ + size_t tag_probe = 0; + if (coinbase_tag && *coinbase_tag) { + size_t t = strlen(coinbase_tag); + tag_probe = (t > 75 ? 75 : t) + 1; + } + size_t ss_probe = (size_t)ss_len + tag_probe + + extranonce1_size + extranonce2_size; + size_t fixed_bytes = 4 + 1 + 36 + (ss_probe < 253 ? 1 : 3) + ss_probe + + 4 + 3 /* output-count varint, conservatively */ + 4; + for (uint64_t i = 0; i < vout; i++) { + if ((int64_t)i == reward_idx) continue; + fixed_bytes += out_ser_size(outs[i].spk_len); + } + /* Hand the reward to the caller's resolver: one miner and a fee, or a * whole PPLNS window. Either way it comes back as concrete outputs, and * this function does not care which it was. */ - if (repl_fn(repl_ctx, reward, repl, CB_MAX_REPL_OUTS, &n_repl, + if (repl_fn(repl_ctx, reward, fixed_bytes, repl, CB_MAX_REPL_OUTS, &n_repl, errbuf, errlen) < 0) goto done; if (n_repl == 0) { set_err(errbuf, errlen, "resolver produced no outputs"); @@ -1194,9 +1261,11 @@ typedef struct { int64_t *out_fee_sats; } repl_single_ctx_t; -static int repl_single(void *vctx, int64_t reward, cb_repl_out_t *out, - size_t cap, size_t *out_n, char *errbuf, size_t errlen) { +static int repl_single(void *vctx, int64_t reward, size_t fixed_bytes, + cb_repl_out_t *out, size_t cap, size_t *out_n, + char *errbuf, size_t errlen) { repl_single_ctx_t *c = vctx; + (void)fixed_bytes; /* one miner and a fee always fit */ if (cap < 2) { set_err(errbuf, errlen, "internal: repl cap"); return -1; } size_t n = 0; int64_t fee_sats = 0, miner_sats = reward; @@ -1233,17 +1302,18 @@ typedef struct { size_t n_payees; const char *operator_address; int fee_bps; - size_t max_payout_outputs; + size_t max_coinbase_bytes; coinbase_window_result_t *res; } repl_window_ctx_t; -static int repl_window(void *vctx, int64_t reward, cb_repl_out_t *out, - size_t cap, size_t *out_n, char *errbuf, size_t errlen) { +static int repl_window(void *vctx, int64_t reward, size_t fixed_bytes, + cb_repl_out_t *out, size_t cap, size_t *out_n, + char *errbuf, size_t errlen) { repl_window_ctx_t *c = vctx; return resolve_window_outputs(reward, c->payees, c->n_payees, c->operator_address, c->fee_bps, - c->max_payout_outputs, out, cap, out_n, - c->res, errbuf, errlen); + c->max_coinbase_bytes, fixed_bytes, + out, cap, out_n, c->res, errbuf, errlen); } /* ---- public template builders ------------------------------------------- */ @@ -1284,7 +1354,7 @@ int coinbase_build_window_from_template(const char *coinbase_tx_hex, const char *coinbase_tag, size_t extranonce1_size, size_t extranonce2_size, - size_t max_payout_outputs, + size_t max_coinbase_bytes, coinbase_parts_t *out, int *out_has_witness, coinbase_window_result_t *res, @@ -1300,7 +1370,7 @@ int coinbase_build_window_from_template(const char *coinbase_tx_hex, ctx.n_payees = n_payees; ctx.operator_address = operator_address; ctx.fee_bps = fee_bps; - ctx.max_payout_outputs = max_payout_outputs; + ctx.max_coinbase_bytes = max_coinbase_bytes; ctx.res = res; return build_from_template_impl(coinbase_tx_hex, repl_window, &ctx, coinbase_tag, extranonce1_size, diff --git a/src/coinbase.h b/src/coinbase.h index 2ec9ca3..d0ea3a2 100644 --- a/src/coinbase.h +++ b/src/coinbase.h @@ -73,18 +73,36 @@ typedef struct { size_t paid_count; /* payees given an output */ int64_t paid_sats; /* summed across those outputs */ size_t dropped_dust; /* payees below COINBASE_DUST_SATS */ - size_t dropped_capped; /* payees past max_payout_outputs */ + size_t dropped_capped; /* payees the byte budget had no room for */ int64_t carry_sats; /* owed to the dropped, paid to the operator */ int64_t fee_sats; /* the operator's actual fee, excluding carry */ } coinbase_window_result_t; -/* A practical ceiling on payout outputs, not a consensus one. - * - * Consensus bounds the coinbase by block weight; at ~31 bytes per P2WPKH - * output even a thousand payees is a low single-digit percentage of the - * budget. The real constraint is that some rented-hashrate marketplaces - * verify a coinbase and reject one they consider oversized, and a delisting - * costs more than paying a few small miners a block later. */ +/* The binding limit on payouts is BYTES, not a count. + * + * The first version of this capped the number of outputs at 200, which was + * wrong in a way only production evidence showed. A rented-hashrate + * marketplace verifies the coinbase and refuses a job whose coinbase it + * considers oversized, and it measures bytes. At ~31 bytes per P2WPKH output, + * 200 payouts is over 6000 bytes of outputs alone — roughly eight times what + * a real coinbase-direct pool is observed to get away with. + * + * Reported from a coinbase-direct PPLNS pool running on the ECX alpha network + * since 2026-08-19 (LayerTwo-Labs/simplepool#61): up to 16 miners paid per + * block, whole coinbases measuring 721–817 bytes. Crucially, the binding term + * there is not the payouts — it is the drivechain OP_RETURNs sharing the same + * transaction. The same 16 payouts cost 817 bytes against four of them and + * 769 against three. A count cap cannot express that; a byte budget can, + * because the commitments are simply part of what has already been spent. + * + * 1000 covers the observed working range with headroom. Configurable via + * coinbase_max_bytes, because the number that matters belongs to whichever + * marketplace an operator is selling to, not to us. */ +#define COINBASE_DEFAULT_MAX_BYTES 1000 + +/* Array bound only. The byte budget is what actually decides how many miners + * are paid; this exists so the builders can use fixed-size storage, and is set + * far above anything the budget will admit. */ #define COINBASE_MAX_PAYOUT_OUTPUTS 200 /* Build cb1/cb2 paying the PPLNS window DIRECTLY, one output per miner. @@ -98,9 +116,12 @@ typedef struct { * fee_bps split every other builder applies. A caller whose arithmetic does * not add up is refused rather than silently underpaying the block. * - * Payees are paid largest first, so the cap and the dust limit fall on the - * smallest claims — the ones for whom waiting a block costs least, and whose - * carried balance is smallest. + * Payees are paid largest first, so the byte budget and the dust limit fall + * on the smallest claims — the ones for whom waiting a block costs least, and + * whose carried balance is smallest. + * + * `max_coinbase_bytes` is the whole serialized coinbase, commitments and all, + * not just the payouts. 0 means COINBASE_DEFAULT_MAX_BYTES. * * Returns 0 ok, negative on error (errbuf populated). `res` may be NULL. */ int coinbase_build_window(uint32_t height, int64_t value_sats, @@ -109,7 +130,7 @@ int coinbase_build_window(uint32_t height, int64_t value_sats, const char *witness_commitment_hex, const char *coinbase_tag, size_t extranonce1_size, size_t extranonce2_size, - size_t max_payout_outputs, + size_t max_coinbase_bytes, coinbase_parts_t *out, coinbase_window_result_t *res, char *errbuf, size_t errlen); @@ -179,7 +200,7 @@ int coinbase_build_window_from_template(const char *coinbase_tx_hex, const char *coinbase_tag, size_t extranonce1_size, size_t extranonce2_size, - size_t max_payout_outputs, + size_t max_coinbase_bytes, coinbase_parts_t *out, int *out_has_witness, coinbase_window_result_t *res, diff --git a/src/config.c b/src/config.c index c836433..3afdc44 100644 --- a/src/config.c +++ b/src/config.c @@ -67,6 +67,7 @@ void proxy_config_defaults(proxy_config_t *cfg) { snprintf(cfg->pool_mode, sizeof cfg->pool_mode, "%s", "solo"); cfg->pplns_window_diff_multiple = 2.0; + cfg->coinbase_max_bytes = 1000; cfg->pool_btc_address[0] = '\0'; cfg->pps_sats_per_diff = 0.0; cfg->pps_min_network_difficulty = 0.0; @@ -282,6 +283,7 @@ int proxy_config_load(const char *path, proxy_config_t *cfg, 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, "coinbase_max_bytes") == 0) cfg->coinbase_max_bytes = atoi(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); @@ -376,6 +378,20 @@ int proxy_config_load(const char *path, proxy_config_t *cfg, return -9; } } + if (mode_cb_window) { + /* The coinbase must have room for the transaction, the commitments + * and at least one payout. Below that no block can pay anyone, which + * is a pool that cannot run rather than one that runs badly. */ + if (cfg->coinbase_max_bytes < 200) { + set_err(errbuf, errlen, + "config: 'coinbase_max_bytes' = %d is too small to hold a " + "coinbase and a single payout; 1000 is the default and a " + "production coinbase-direct pool reports 721-817 bytes " + "paying up to 16 miners", + cfg->coinbase_max_bytes); + return -14; + } + } if (mode_pplns) { if (!(cfg->pplns_window_diff_multiple > 0.0)) { set_err(errbuf, errlen, diff --git a/src/config.h b/src/config.h index 567c534..8a1e238 100644 --- a/src/config.h +++ b/src/config.h @@ -117,6 +117,16 @@ typedef struct { * 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 */ + /* pplns-coinbase: the whole serialized coinbase's byte budget, which is + * what actually limits how many miners a block can pay. + * + * Not a consensus limit. A rented-hashrate marketplace verifies the + * coinbase and refuses a job whose coinbase it considers oversized, and + * the number that matters therefore belongs to whichever marketplace an + * operator sells to. A coinbase-direct pool in production reports whole + * coinbases of 721-817 bytes paying up to 16 miners; the default leaves + * headroom on that. 0 = COINBASE_DEFAULT_MAX_BYTES. */ + int coinbase_max_bytes; /* default 1000 */ /* pooled modes: coinbase pays this BTC address (P2WPKH/P2PKH/P2SH) for * the net-of-fee reward. Required when pool_mode = pps-classic; diff --git a/src/main.c b/src/main.c index 96e24ab..e7bf04d 100644 --- a/src/main.c +++ b/src/main.c @@ -1295,6 +1295,7 @@ int main(int argc, char **argv) { stcfg.coinbase_pays_pool = mode_pps_classic || mode_pplns_thunder || mode_pplns_btc; stcfg.coinbase_pays_window = mode_pplns_cb; + stcfg.max_coinbase_bytes = (size_t)cfg.coinbase_max_bytes; 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); diff --git a/src/stratum.c b/src/stratum.c index d752e4e..a61a6b1 100644 --- a/src/stratum.c +++ b/src/stratum.c @@ -777,14 +777,14 @@ static int conn_render_coinbase(stratum_server_t *s, stratum_conn_t *c, job->coinbasetxn_hex, job->payees, job->n_payees, s->cfg.operator_address, s->cfg.fee_bps, s->cfg.coinbase_tag, job->en1_size, job->en2_size, - s->cfg.max_payout_outputs, &parts, NULL, NULL, + s->cfg.max_coinbase_bytes, &parts, NULL, NULL, err, sizeof err); } else { rc = coinbase_build_window( job->height, job->value_sats, job->payees, job->n_payees, s->cfg.operator_address, s->cfg.fee_bps, job->wc_hex, s->cfg.coinbase_tag, job->en1_size, job->en2_size, - s->cfg.max_payout_outputs, &parts, NULL, err, sizeof err); + s->cfg.max_coinbase_bytes, &parts, NULL, err, sizeof err); } } else if (s->cfg.coinbase_pays_pool) { /* PPS-classic: every miner's coinbase is identical, paying the diff --git a/src/stratum.h b/src/stratum.h index 38262f6..a0fbd19 100644 --- a/src/stratum.h +++ b/src/stratum.h @@ -204,7 +204,7 @@ typedef struct { * The window itself rides on the job (stratum_job_set_window), because it * is a snapshot taken when the template was built. */ int coinbase_pays_window; - size_t max_payout_outputs; /* 0 = COINBASE_MAX_PAYOUT_OUTPUTS */ + size_t max_coinbase_bytes; /* 0 = COINBASE_DEFAULT_MAX_BYTES */ /* 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 diff --git a/tests/test_coinbase.c b/tests/test_coinbase.c index db20c9c..43844e1 100644 --- a/tests/test_coinbase.c +++ b/tests/test_coinbase.c @@ -916,7 +916,14 @@ static void test_the_cap_falls_on_the_smallest_claims(void) { * carry needs somewhere to ride even when there is no fee. */ int rc = coinbase_build_window(800000, value, payees, 3, WOP, 0, NULL, NULL, 4, 8, - 2, &parts, &res, err, sizeof err); + /* Byte budget admitting exactly two of the + * three payouts: the envelope, scriptSig + * and reserved operator output come to + * 112 bytes, and each P2WPKH payout costs + * 31, so 174 fits two and 205 would fit + * three. */ + 180, + &parts, &res, err, sizeof err); assert(rc == 0); assert(res.paid_count == 2); assert(res.dropped_capped == 1); @@ -1127,8 +1134,102 @@ static void test_both_window_builders_split_identically(void) { printf("ok: both window builders split a window identically\n"); } +/* The budget is BYTES, and the commitments are part of what spends them. + * + * This is the correction that production evidence forced. The first version + * capped payouts at a count, which cannot express the thing that actually + * binds: a coinbase-direct pool reports the same 16 payouts costing 817 bytes + * against four drivechain OP_RETURNs and 769 against three + * (LayerTwo-Labs/simplepool#61). The commitments are not payouts and a count + * cap cannot see them; a byte budget spends them first and pays whoever is + * left over. + * + * Asserted as a relationship rather than against somebody else's absolute + * numbers: the same window, the same budget, a template carrying more + * commitment bytes -> strictly fewer miners paid. */ +static void test_commitments_eat_the_payout_budget(void) { + char err[256] = {0}; + coinbase_parts_t parts; + coinbase_window_result_t res; + + coinbase_parts_t probe; int64_t reward = 0, unused = 0; + assert(coinbase_build_from_template(ENF_COINBASE_HEX, ENF_ADDR, NULL, 0, + NULL, 4, 4, &probe, NULL, &reward, + &unused, err, sizeof err) == 0); + coinbase_parts_free(&probe); + + /* Eight equal claims, all comfortably above dust. */ + enum { N = 8 }; + coinbase_payee_t payees[N]; + int64_t each = reward / N; + for (int i = 0; i < N; ++i) { + payees[i].address = (i % 2) ? WA : WB; + payees[i].sats = each; + } + payees[0].sats += reward - each * N; /* exact */ + + /* Generous enough to admit several of the eight, tight enough that the + * commitments make a visible difference. Measured: at this budget the + * enforcer template admits 5 and a bare coinbase admits 6. */ + const size_t BUDGET = 300; + assert(coinbase_build_window_from_template(ENF_COINBASE_HEX, payees, N, + WOP, 0, NULL, 4, 4, BUDGET, + &parts, NULL, &res, + err, sizeof err) == 0); + size_t paid_with_template = res.paid_count; + assert(paid_with_template > 0 && paid_with_template < N); + /* Nothing is lost: whatever did not fit is carried, not dropped. */ + assert(res.dropped_capped == N - paid_with_template); + assert(res.paid_sats + res.carry_sats + res.fee_sats == reward); + coinbase_parts_free(&parts); + + /* The same window and the same budget, built from scratch — no template, + * so no commitment OP_RETURNs spending the budget. More miners fit. */ + assert(coinbase_build_window(800000, reward, payees, N, WOP, 0, NULL, + NULL, 4, 4, BUDGET, &parts, &res, + err, sizeof err) == 0); + assert(res.paid_count > paid_with_template); + coinbase_parts_free(&parts); + printf("ok: commitments spend the byte budget, so fewer miners fit (%zu vs %zu)\n", + paid_with_template, res.paid_count); +} + +/* Whatever the budget says, the coinbase must actually come in under it — + * the number is only worth having if it is true of the bytes on the wire. */ +static void test_the_built_coinbase_respects_its_budget(void) { + char err[256] = {0}; + coinbase_parts_t parts; + coinbase_window_result_t res; + + enum { N = 12 }; + coinbase_payee_t payees[N]; + int64_t total = 5000000000LL; + int64_t each = total / N; + for (int i = 0; i < N; ++i) { + payees[i].address = (i % 2) ? WA : WB; + payees[i].sats = each; + } + payees[0].sats += total - each * N; + + for (size_t budget = 200; budget <= 600; budget += 100) { + assert(coinbase_build_window(800000, total, payees, N, WOP, 0, NULL, + "/sp/", 4, 8, budget, &parts, &res, + err, sizeof err) == 0); + /* cb1 + extranonce + cb2 is the whole serialized coinbase. */ + size_t built = parts.cb1_len + 12 + parts.cb2_len; + if (built > budget) { + fprintf(stderr, "FAIL: budget %zu produced %zu bytes\n", budget, built); + assert(0); + } + coinbase_parts_free(&parts); + } + printf("ok: a built coinbase never exceeds its byte budget\n"); +} + int main(void) { test_p2pkh_address(); + test_the_built_coinbase_respects_its_budget(); + test_commitments_eat_the_payout_budget(); test_both_window_builders_split_identically(); test_window_from_template_preserves_commitments(); test_window_pays_each_miner_its_own_output(); diff --git a/tests/test_config.c b/tests/test_config.c index 43c1e31..4ae5c6f 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -244,6 +244,33 @@ static void test_pplns_coinbase_validates_the_window(void) { CHECK(strstr(err, "pplns_window_diff_multiple") != NULL); } +/* The byte budget is what limits how many miners a block can pay, so a value + * too small to hold even one payout is a pool that cannot run at all. */ +static void test_a_tiny_coinbase_budget_is_refused(void) { + proxy_config_t cfg; char err[256] = {0}; + char body[512]; + snprintf(body, sizeof body, + "operator_address = %s\npool_mode = pplns-coinbase\n" + "coinbase_max_bytes = 150\n", VALID_ADDR); + CHECK(load_text(body, &cfg, err, sizeof err) != 0); + CHECK(strstr(err, "coinbase_max_bytes") != NULL); +} + +static void test_the_coinbase_budget_defaults_and_parses(void) { + proxy_config_t cfg; char err[256] = {0}; + char body[512]; + snprintf(body, sizeof body, + "operator_address = %s\npool_mode = pplns-coinbase\n", VALID_ADDR); + CHECK(load_text(body, &cfg, err, sizeof err) == 0); + CHECK(cfg.coinbase_max_bytes == 1000); + + snprintf(body, sizeof body, + "operator_address = %s\npool_mode = pplns-coinbase\n" + "coinbase_max_bytes = 820\n", VALID_ADDR); + CHECK(load_text(body, &cfg, err, sizeof err) == 0); + CHECK(cfg.coinbase_max_bytes == 820); +} + /* ---- listener lines ------------------------------------------------------ * * A `listener` line is how rented hashrate is served its own difficulty. A @@ -334,6 +361,8 @@ int main(void) { test_quoted_value_keeps_hash(); test_inline_comment_still_strips(); test_rejects_bad_operator_address(); + test_the_coinbase_budget_defaults_and_parses(); + test_a_tiny_coinbase_budget_is_refused(); test_pplns_coinbase_validates_the_window(); test_pplns_coinbase_refuses_a_pool_wallet(); test_pplns_coinbase_is_accepted_without_a_pool_wallet(); From 47f9ab42a48811925fd98b0a588b438461f0424e Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 8 Sep 2026 11:03:11 +0200 Subject: [PATCH 07/36] cbwin e2e: wait for the new-height job before mining the second block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught a race my local runs hid. The second miner connected while the pool was still serving the job for the height just mined — the tip watcher polls every 500ms — so it mined a SIBLING of that block rather than a successor: submitblock -> null (first block, accepted) submitblock -> "inconclusive" (sibling, 60ms later, rejected) height: 11 -> 11 "inconclusive" is bitcoind saying the block neither extends nor replaces the tip, which is exactly what a sibling is. Nothing to do with the mode, the window or the coinbase; the stage was simply mining against a stale job. It now waits for the pool to publish a job at the next height before starting the second miner, and says which height it is serving. A timing assumption that happens to hold on one machine is not a test. --- tests/test_pplns_coinbase_regtest.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_pplns_coinbase_regtest.sh b/tests/test_pplns_coinbase_regtest.sh index 045b3f6..d9673f7 100755 --- a/tests/test_pplns_coinbase_regtest.sh +++ b/tests/test_pplns_coinbase_regtest.sh @@ -243,6 +243,24 @@ stage "mine a SECOND block, now that a window exists" # The first block proved bootstrap. This one proves the mode: shares exist # now, so the job carries a real window and the coinbase is built from it # rather than from the connection. +# +# Wait for the pool to publish a job at the NEW height first. Without this the +# second miner connects while the pool is still serving the height-N job — the +# tip watcher polls every 500ms — mines a SIBLING of the block just found, and +# submitblock answers "inconclusive" because it neither extends nor replaces +# the tip. The chain then reads N -> N and the stage fails for a reason that +# has nothing to do with the mode. CI caught exactly that; locally the timing +# happened to hide it. +NEXT_HEIGHT=$((TIP_AFTER + 1)) +for _ in $(seq 1 40); do + grep -q "new job: height=${NEXT_HEIGHT} " "$POOL_LOG" && break + sleep 1 +done +grep -q "new job: height=${NEXT_HEIGHT} " "$POOL_LOG" || { + echo "FAIL: the pool never published a job at height ${NEXT_HEIGHT}" >&2 + exit 1; } +echo " pool is serving height ${NEXT_HEIGHT}" + TIP_BEFORE2=$(cli getblockcount) node "$ROOT/scripts/regtest/cpuminer.js" --port "$POOL_PORT" --user "$MINER_ADDR" --timeout 180 TIP_AFTER2=$(cli getblockcount) From 199d1048a3b79569698770d9ea870754d88bdccc Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 8 Sep 2026 12:43:36 +0200 Subject: [PATCH 08/36] Coinbase-direct PPLNS: record who is owed the carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now carry_sats was computed, reported, and then forgotten. A claim too small for a coinbase output rode on the operator output — so the operator was holding it — and nothing anywhere said whose it was. That is not a small custodial balance, it is money kept quietly. Wired4ncer names this as the mode's first cost on #61 and reports 62 of 100 addresses currently sitting below the floor, so it is the common case rather than an edge. The ledger is now written per worker, into the same pps_credits table the other rails use, so a carried claim shows up wherever a miner's balance already shows up. Three decisions in that: Only the UNPAID part is recorded. A claim the coinbase paid is settled on chain and has no business in a ledger of what is owed; a partly paid claim carries only its remainder. Recording the full claim would have the pool owing money it had already paid, and a zero row would put every settled miner into a ledger of debts the pool does not have. It is written when a block is FOUND, which is the only moment the information exists. The coinbase is decided when the template is built, but almost no template becomes a block, so nothing can be written earlier without recording debts for blocks that were never mined. The outcome is recomputed at that moment rather than remembered. The split is deterministic from the job and the config, so running the same builder again reproduces exactly what was rendered, and a found block is rare enough that the cost is irrelevant. The alternative — carrying the result along the submit path — would mean the ledger and the coinbase could disagree. Worker ids now ride on the job beside the payees, because an address is not enough: two rigs can share one and the ledger is per worker. Four store tests, mutation-verified: recording the full claim instead of the remainder fails them. Two things worth stating plainly. The e2e does NOT cover carry with anything in it. Carry needs a window where some claims fit the coinbase and others do not, which needs several miners of very different sizes, and the harness drives one cpuminer. Squeezing the byte budget instead does not produce it either: when nothing fits, the builder refuses, no coinbase is rendered, and no block is found. I wrote that stage, watched it print "carried: 0 sats" and pass regardless, and removed it — a stage that cannot fail is worse than an absent one. The gap is now stated in the file rather than papered over. And carry is recorded but not yet CLEARED: a carried claim does not currently raise the miner's share of a later block. Clearing it has to come out of the operator's fee, since the money is already on chain in the operator's output, and that is a design question rather than an oversight — it is the next piece. --- src/coinbase.c | 2 + src/coinbase.h | 53 ++++++----- src/main.c | 36 +++++++- src/store.c | 61 +++++++++++++ src/store.h | 24 +++++ src/stratum.c | 63 ++++++++++++- src/stratum.h | 21 ++++- tests/test_pplns_coinbase_regtest.sh | 37 ++++++-- tests/test_store.c | 132 +++++++++++++++++++++++++++ tests/test_stratum.c | 2 +- 10 files changed, 393 insertions(+), 38 deletions(-) diff --git a/src/coinbase.c b/src/coinbase.c index bb3dc7a..bfcc532 100644 --- a/src/coinbase.c +++ b/src/coinbase.c @@ -861,6 +861,8 @@ static int resolve_window_outputs(int64_t value_sats, payout_bytes += cost; out[n].sats = pe->sats; r.paid_sats += pe->sats; + if (rank[k].idx < COINBASE_MAX_PAYOUT_OUTPUTS) + r.paid_per_payee[rank[k].idx] = pe->sats; n++; r.paid_count++; } free(rank); diff --git a/src/coinbase.h b/src/coinbase.h index d0ea3a2..59224b7 100644 --- a/src/coinbase.h +++ b/src/coinbase.h @@ -56,28 +56,6 @@ typedef struct { int64_t sats; /* what the window entitles this miner to */ } coinbase_payee_t; -/* What the builder actually managed to pay, and what it could not. - * - * `carry_sats` is the honest part. A payee below the dust limit, or past the - * output cap, cannot be paid in THIS coinbase — but its value cannot simply - * vanish either: a coinbase that pays out less than it is allowed forfeits - * the difference to nobody. So the shortfall is added to the operator output - * and reported here, which means the pool is holding it and owes it. - * - * That is the cost the design has to own: coinbase-direct removes custody for - * everyone the block can pay, and replaces it with a small, bounded, - * disclosable balance for everyone it cannot. It is not "zero custody"; it is - * custody proportional to dust, and the number is right here rather than - * implied. */ -typedef struct { - size_t paid_count; /* payees given an output */ - int64_t paid_sats; /* summed across those outputs */ - size_t dropped_dust; /* payees below COINBASE_DUST_SATS */ - size_t dropped_capped; /* payees the byte budget had no room for */ - int64_t carry_sats; /* owed to the dropped, paid to the operator */ - int64_t fee_sats; /* the operator's actual fee, excluding carry */ -} coinbase_window_result_t; - /* The binding limit on payouts is BYTES, not a count. * * The first version of this capped the number of outputs at 200, which was @@ -105,6 +83,37 @@ typedef struct { * far above anything the budget will admit. */ #define COINBASE_MAX_PAYOUT_OUTPUTS 200 +/* What the builder actually managed to pay, and what it could not. + * + * `carry_sats` is the honest part. A payee below the dust limit, or past the + * output cap, cannot be paid in THIS coinbase — but its value cannot simply + * vanish either: a coinbase that pays out less than it is allowed forfeits + * the difference to nobody. So the shortfall is added to the operator output + * and reported here, which means the pool is holding it and owes it. + * + * That is the cost the design has to own: coinbase-direct removes custody for + * everyone the block can pay, and replaces it with a small, bounded, + * disclosable balance for everyone it cannot. It is not "zero custody"; it is + * custody proportional to dust, and the number is right here rather than + * implied. */ +typedef struct { + size_t paid_count; /* payees given an output */ + int64_t paid_sats; /* summed across those outputs */ + size_t dropped_dust; /* payees below COINBASE_DUST_SATS */ + size_t dropped_capped; /* payees the byte budget had no room for */ + int64_t carry_sats; /* owed to the dropped, paid to the operator */ + int64_t fee_sats; /* the operator's actual fee, excluding carry */ + /* What each payee actually received, indexed as the CALLER passed them — + * not in the largest-first order the builder pays in. 0 means the payee + * carried: it was below the floor, or the byte budget had no room. + * + * Without this the carry is a single number and nobody knows whose it is. + * A pool that cannot say which miner is owed the dust is not running a + * ledger, it is just keeping the money. */ + int64_t paid_per_payee[COINBASE_MAX_PAYOUT_OUTPUTS]; +} coinbase_window_result_t; + + /* Build cb1/cb2 paying the PPLNS window DIRECTLY, one output per miner. * * The point of the mode: the pool never receives the reward, so there is no diff --git a/src/main.c b/src/main.c index e7bf04d..316642b 100644 --- a/src/main.c +++ b/src/main.c @@ -183,6 +183,37 @@ static double effective_pps_rate(const proxy_config_t *cfg, /* Build a job from a freshly fetched template. The coinbase is rendered * per-connection inside stratum.c (each miner pays their own address), * so we only pass template-level data here. */ +/* What a found block's coinbase actually paid, and what it carried. + * + * Only the carried part is recorded. A claim the coinbase paid is settled on + * chain and has no business in a ledger of what is owed; a claim it could not + * pay rode on the operator output, which means the operator is holding it. */ +static void on_window_outcome_cb(void *ctx, const char *block_hash, + const int64_t *worker_ids, + const int64_t *owed_sats, + const int64_t *paid_sats, size_t n) { + server_ctx_t *s = (server_ctx_t *)ctx; + if (!s || !s->store) return; + char werr[256] = {0}; + int rc = store_record_window_carry(s->store, worker_ids, owed_sats, + paid_sats, n, werr, sizeof werr); + if (rc < 0) { + LOG_WARN("pplns-coinbase: could not record carried claims for block " + "%.16s: %s — the operator is holding money the ledger does " + "not know about", block_hash ? block_hash : "?", werr); + return; + } + if (rc > 0) { + int64_t carried = 0; + for (size_t i = 0; i < n; ++i) carried += owed_sats[i] - paid_sats[i]; + LOG_INFO("pplns-coinbase: block %.16s paid %zu miner(s) directly; " + "%d claim(s) totalling %lld sats were below the floor or out " + "of coinbase room and are carried", + block_hash ? block_hash : "?", n - (size_t)rc, rc, + (long long)carried); + } +} + /* Snapshot the PPLNS window onto a freshly built job, for pplns-coinbase. * * The window is taken from the template that is about to go out, so the @@ -267,8 +298,10 @@ static int attach_pplns_window(store_t *store, const proxy_config_t *cfg, } coinbase_payee_t payees[COINBASE_MAX_PAYOUT_OUTPUTS]; + int64_t worker_ids[COINBASE_MAX_PAYOUT_OUTPUTS]; int64_t assigned = 0; for (size_t i = 0; i < n; ++i) { + worker_ids[i] = win[i].worker_id; payees[i].address = win[i].payout_address; payees[i].sats = (int64_t)((double)payable * (win[i].difficulty / total)); assigned += payees[i].sats; @@ -280,7 +313,7 @@ static int attach_pplns_window(store_t *store, const proxy_config_t *cfg, * A handful of satoshis, to the miner with the strongest claim on them. */ if (assigned < payable) payees[0].sats += payable - assigned; - if (stratum_job_set_window(job, payees, n) < 0) { + if (stratum_job_set_window(job, payees, worker_ids, n) < 0) { LOG_WARN("pplns-coinbase: could not attach the window to the job"); return -1; } @@ -1295,6 +1328,7 @@ int main(int argc, char **argv) { stcfg.coinbase_pays_pool = mode_pps_classic || mode_pplns_thunder || mode_pplns_btc; stcfg.coinbase_pays_window = mode_pplns_cb; + stcfg.on_window_outcome = mode_pplns_cb ? on_window_outcome_cb : NULL; stcfg.max_coinbase_bytes = (size_t)cfg.coinbase_max_bytes; stcfg.username_is_thunder = mode_pps_classic || mode_pplns_thunder; snprintf(stcfg.pool_btc_address, sizeof stcfg.pool_btc_address, "%s", diff --git a/src/store.c b/src/store.c index 1b4bc2f..9bcd002 100644 --- a/src/store.c +++ b/src/store.c @@ -1498,6 +1498,67 @@ int store_pplns_window(store_t *s, double window_diff, return (int)n; } +int store_record_window_carry(store_t *s, + const int64_t *worker_ids, + const int64_t *owed_sats, + const int64_t *paid_sats, + size_t n, char *errbuf, size_t errlen) +{ + if (!s || !s->db || !worker_ids || !owed_sats || !paid_sats) { + if (errbuf && errlen) snprintf(errbuf, errlen, "bad arg"); + return -1; + } + if (n == 0) return 0; + + static const char *Q = + "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"; + + /* One transaction for the whole block: either every carried claim is + * recorded or none is. A partial record is the failure that cannot be + * repaired by running again, because there is no second chance to see + * this coinbase. */ + if (sqlite3_exec(s->db, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) { + if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); + return -1; + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(s->db, Q, -1, &st, NULL) != SQLITE_OK) { + if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); + sqlite3_exec(s->db, "ROLLBACK", NULL, NULL, NULL); + atomic_fetch_add(&s->pg_errors, 1); + return -2; + } + int credited = 0, ok = 1; + sqlite3_int64 now = (sqlite3_int64)time(NULL); + for (size_t i = 0; i < n; ++i) { + int64_t carried = owed_sats[i] - paid_sats[i]; + /* Paid in full on chain: nothing is owed, so nothing is recorded. + * Writing a zero row would put every miner in a ledger of debts the + * pool does not have. */ + if (carried <= 0 || worker_ids[i] <= 0) continue; + sqlite3_bind_int64(st, 1, worker_ids[i]); + sqlite3_bind_int64(st, 2, carried); + sqlite3_bind_int64(st, 3, now); + if (sqlite3_step(st) != SQLITE_DONE) { ok = 0; } + sqlite3_reset(st); + if (!ok) break; + credited++; + } + sqlite3_finalize(st); + if (!ok) { + sqlite3_exec(s->db, "ROLLBACK", NULL, NULL, NULL); + if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); + atomic_fetch_add(&s->pg_errors, 1); + return -2; + } + sqlite3_exec(s->db, "COMMIT", NULL, NULL, NULL); + return credited; +} + 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 a223552..4b30bbc 100644 --- a/src/store.h +++ b/src/store.h @@ -198,6 +198,30 @@ int store_pplns_window(store_t *s, double window_diff, size_t *out_n, double *out_total_diff, int *out_truncated, char *errbuf, size_t errlen); +/* Record what a found block's coinbase paid, and what it did not. + * + * pplns-coinbase pays miners in the block itself, so most of the ledger the + * other rails keep is unnecessary — with one exception. A claim too small to + * put in a coinbase output, or one the byte budget had no room for, is not + * paid and is not lost: it rides on the operator output, which means the + * operator is holding it and owes it. + * + * That debt is recorded here, in the same pps_credits table the other rails + * use, so it appears wherever a miner's balance already appears. accrued_sats + * is incremented by the carried amount and nothing else: a claim the coinbase + * paid is settled on chain and has no business in a ledger of what is owed. + * + * This is the honest form of the cost the mode has to admit to. It is not + * zero custody; it is custody proportional to dust, recorded per worker + * rather than implied. + * + * Returns the number of workers credited, or negative on error. */ +int store_record_window_carry(store_t *s, + const int64_t *worker_ids, + const int64_t *owed_sats, + const int64_t *paid_sats, + size_t n, 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 * share_hash semantics match store_record_share() above. diff --git a/src/stratum.c b/src/stratum.c index a61a6b1..052438e 100644 --- a/src/stratum.c +++ b/src/stratum.c @@ -138,6 +138,7 @@ struct stratum_job { * allocations rather than one per miner. */ coinbase_payee_t *payees; char *payee_addrs; + int64_t *payee_worker_ids; size_t n_payees; uint64_t created_ms; /* for retention ring */ @@ -242,6 +243,7 @@ void stratum_job_free(stratum_job_t *j) { } free(j->payees); free(j->payee_addrs); + free(j->payee_worker_ids); free(j); } @@ -249,25 +251,33 @@ void stratum_job_free(stratum_job_t *j) { * of payees and one arena the addresses live in, so a 200-miner window is not * 200 strdups that have to be unwound on every job retirement. */ int stratum_job_set_window(stratum_job_t *j, - const coinbase_payee_t *payees, size_t n_payees) { + const coinbase_payee_t *payees, + const int64_t *worker_ids, size_t n_payees) { if (!j) return -1; - free(j->payees); j->payees = NULL; - free(j->payee_addrs); j->payee_addrs = NULL; + free(j->payees); j->payees = NULL; + free(j->payee_addrs); j->payee_addrs = NULL; + free(j->payee_worker_ids); j->payee_worker_ids = NULL; j->n_payees = 0; if (!payees || n_payees == 0) return 0; enum { ADDR_STRIDE = 128 }; coinbase_payee_t *arr = calloc(n_payees, sizeof *arr); char *arena = calloc(n_payees, ADDR_STRIDE); - if (!arr || !arena) { free(arr); free(arena); return -1; } + /* Worker ids ride alongside so a found block can name who carried. An + * address is not enough: two rigs can share one, and the ledger is per + * worker. */ + int64_t *ids = calloc(n_payees, sizeof *ids); + if (!arr || !arena || !ids) { free(arr); free(arena); free(ids); return -1; } for (size_t i = 0; i < n_payees; ++i) { char *dst = arena + i * ADDR_STRIDE; snprintf(dst, ADDR_STRIDE, "%s", payees[i].address ? payees[i].address : ""); arr[i].address = dst; arr[i].sats = payees[i].sats; + ids[i] = worker_ids ? worker_ids[i] : 0; } j->payees = arr; j->payee_addrs = arena; + j->payee_worker_ids = ids; j->n_payees = n_payees; return 0; } @@ -2034,6 +2044,51 @@ static int submit_with_job(stratum_server_t *s, stratum_conn_t *c, cJSON *id, c->vd_window_max_assigned = assigned_diff; } vardiff_maybe_retarget(s, c, now_ms(), buf, len); + /* Who this block's coinbase actually paid. Recomputed rather than + * remembered: the split is deterministic from the job and the config, so + * running the same builder again reproduces exactly what was rendered, + * and a found block is rare enough that the cost does not matter. Doing + * it here is the only option — the coinbase is decided when the template + * is built, but almost no template becomes a block, so nothing can be + * written to a ledger until one does. */ + if (is_block && block_accepted && s->cfg.on_window_outcome && + s->cfg.coinbase_pays_window && job->payees && job->n_payees > 0) { + coinbase_parts_t throwaway; + coinbase_window_result_t res; + char werr[256] = {0}; + int wrc; + if (job->coinbasetxn_hex) { + wrc = coinbase_build_window_from_template( + job->coinbasetxn_hex, job->payees, job->n_payees, + s->cfg.operator_address, s->cfg.fee_bps, + s->cfg.coinbase_tag, job->en1_size, job->en2_size, + s->cfg.max_coinbase_bytes, &throwaway, NULL, &res, + werr, sizeof werr); + } else { + wrc = coinbase_build_window( + job->height, job->value_sats, job->payees, job->n_payees, + s->cfg.operator_address, s->cfg.fee_bps, job->wc_hex, + s->cfg.coinbase_tag, job->en1_size, job->en2_size, + s->cfg.max_coinbase_bytes, &throwaway, &res, + werr, sizeof werr); + } + if (wrc == 0) { + coinbase_parts_free(&throwaway); + int64_t owed[COINBASE_MAX_PAYOUT_OUTPUTS]; + size_t n = job->n_payees > COINBASE_MAX_PAYOUT_OUTPUTS + ? COINBASE_MAX_PAYOUT_OUTPUTS : job->n_payees; + for (size_t i = 0; i < n; ++i) owed[i] = job->payees[i].sats; + s->cfg.on_window_outcome(s->cfg.ctx, block_hash_hex, + job->payee_worker_ids, owed, + res.paid_per_payee, n); + } else { + /* The ledger is the only record of who is owed, so failing to + * write it is worth saying loudly even though the block stands. */ + LOG_WARN("stratum: could not determine the window outcome for " + "block %s: %s — carried claims are unrecorded", + block_hash_hex, werr); + } + } if (is_block && s->cfg.on_block_found) { int64_t fee_sats = 0; if (s->cfg.fee_bps > 0 && s->cfg.operator_address[0]) { diff --git a/src/stratum.h b/src/stratum.h index a0fbd19..6ccd9a8 100644 --- a/src/stratum.h +++ b/src/stratum.h @@ -78,7 +78,8 @@ stratum_job_t *stratum_job_new( * * Returns 0 on success, negative on allocation failure. */ int stratum_job_set_window(stratum_job_t *j, - const coinbase_payee_t *payees, size_t n_payees); + const coinbase_payee_t *payees, + const int64_t *worker_ids, size_t n_payees); void stratum_job_free(stratum_job_t *j); @@ -106,6 +107,23 @@ typedef int (*block_submit_fn)(void *ctx, const char *block_hex, * `submit_error` the reason when it was not. A candidate the node refused is * still reported here — it is recorded as 'rejected' rather than dropped, * because a silent reject is how phantom rewards went unnoticed. */ +/* Who the coinbase of a found block actually paid, and who it did not. + * + * Called once per found block in pplns-coinbase mode, before on_block_found. + * `paid_sats[i]` is what worker_ids[i] received in that coinbase; 0 means the + * claim carried, because it was below the floor or the byte budget had no + * room. `owed_sats[i]` is what it was entitled to either way. + * + * This is the only moment the information exists: the coinbase is decided + * when the template is built, but almost every template never becomes a + * block, so nothing can be written to a ledger until one does. */ +typedef void (*window_outcome_fn)(void *ctx, + const char *block_hash, + const int64_t *worker_ids, + const int64_t *owed_sats, + const int64_t *paid_sats, + size_t n); + typedef void (*block_found_fn)(void *ctx, const char *worker_name, const char *finder_address, @@ -272,6 +290,7 @@ typedef struct { reject_observer_fn on_reject; block_submit_fn on_block; block_found_fn on_block_found; + window_outcome_fn on_window_outcome; /* pplns-coinbase only */ } stratum_cfg_t; typedef struct stratum_server stratum_server_t; diff --git a/tests/test_pplns_coinbase_regtest.sh b/tests/test_pplns_coinbase_regtest.sh index d9673f7..74ca2a4 100755 --- a/tests/test_pplns_coinbase_regtest.sh +++ b/tests/test_pplns_coinbase_regtest.sh @@ -23,9 +23,23 @@ # the chain rather than out of anything simplepool wrote. # 3. NO output pays an address the pool controls. That is the whole claim of # the mode and it is the one thing a bookkeeping bug cannot fake. -# 4. pps_credits stays empty. Nothing accrues off-chain because nothing is -# owed off-chain — a balance here would mean the pool thinks it owes -# money it already paid on-chain. +# 4. only UNPAID claims are owed off-chain. A miner the coinbase paid must +# not appear in pps_credits at all: a row there would mean the pool +# believes it owes money it already paid on chain. +# +# NOT covered here, deliberately: the carry ledger with something actually in +# it. Carry needs a window where some claims fit the coinbase and others do +# not, which needs several miners of very different sizes — and this harness +# drives one cpuminer. Shrinking the byte budget instead does not produce it +# either: when NOTHING fits, the builder refuses, no coinbase is rendered and +# no block is found, so there is nothing to record. +# +# An earlier version of this file had a stage that squeezed the budget and +# printed how much had carried. It printed 0 every time and passed regardless, +# which is worse than no stage at all. The carry ledger is covered by +# tests/test_store.c instead, where the outcome can be stated exactly and is +# mutation-verified; what is missing is an end-to-end run with a mixed-size +# window, and it is missing on purpose rather than by oversight. # # Env: # REGTEST_DIR data dir, WIPED each run (default: /.regtest-cbwin) @@ -297,16 +311,21 @@ if unknown: print(f" miner {paid[miner]} sats, operator {paid.get(op, 0)} sats") PY -stage "assert nothing accrued off-chain" -# The payment was the block. A pps_credits row here would mean the pool -# believes it owes money it has already paid on-chain — the double-payment -# this mode exists to make impossible. +stage "assert only UNPAID claims are owed off-chain" +# The payment was the block, so a miner the coinbase paid must not appear in +# pps_credits at all: a row there would mean the pool believes it owes money +# it has already paid on chain. +# +# The ledger is not empty by definition, though. A claim below the payout +# floor, or one the byte budget had no room for, rides on the operator output +# — the operator is holding it, and owes it. Here the single miner takes the +# whole block and clears the floor easily, so nothing should carry. CREDITS="$(sqlite3 "$POOL_DB" "SELECT COALESCE(SUM(accrued_sats),0) FROM pps_credits")" ROWS="$(sqlite3 "$POOL_DB" "SELECT COUNT(*) FROM pps_credits")" echo " pps_credits rows=$ROWS accrued=$CREDITS" [ "$ROWS" = "0" ] && [ "$CREDITS" = "0" ] || { - echo "FAIL: pplns-coinbase accrued $CREDITS sats off-chain across $ROWS row(s);" >&2 - echo " the coinbase already paid the miners" >&2 + echo "FAIL: pplns-coinbase recorded $CREDITS sats owed across $ROWS row(s)," >&2 + echo " but the coinbase paid this miner in full" >&2 exit 1; } stage "assert the block was recorded, and needs no distribution" diff --git a/tests/test_store.c b/tests/test_store.c index 02cc4e8..6d0ee0b 100644 --- a/tests/test_store.c +++ b/tests/test_store.c @@ -58,6 +58,16 @@ static int64_t scalar_i64(sqlite3 *db, const char *sql) { return v; } +/* Same as scalar_i64 but opens the file itself, for assertions made after + * store_close(). */ +static int64_t scalar_path(const char *path, const char *sql) { + sqlite3 *db = NULL; + assert(sqlite3_open(path, &db) == SQLITE_OK); + int64_t v = scalar_i64(db, sql); + sqlite3_close(db); + return v; +} + /* Copies into `out` because the sqlite3_stmt is finalized before returning. * Writes "" for SQL NULL, and returns whether the column was non-NULL — the * identity test needs to tell "stored blank" from "stored nothing". */ @@ -1449,6 +1459,124 @@ static void test_an_empty_window_returns_nothing_not_an_error(void) { printf(" ok test_an_empty_window_returns_nothing_not_an_error\n"); } +/* ---- the coinbase-direct carry ledger ---------------------------------- + * + * pplns-coinbase pays miners in the block itself, so there is almost no + * ledger — except for the claims the coinbase could NOT carry: below the + * payout floor, or past the byte budget. Those ride on the operator output, + * which means the operator holds them and owes them. + * + * Recording that is what makes "a small custodial balance" an honest + * statement rather than a hidden one. */ +static void test_only_unpaid_claims_are_recorded_as_owed(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); + + /* Three workers exist. */ + for (int i = 0; i < 3; ++i) { + char name[16], addr[16]; + snprintf(name, sizeof name, "w%d", i + 1); + snprintf(addr, sizeof addr, "addr_%d", i + 1); + assert(store_record_share_addr(s, name, addr, 1000ULL + (uint64_t)i, + 1.0, 0, NULL, 0, 0.0) == 0); + } + assert(store_flush(s) == 0); + + /* w1 paid in full, w2 paid nothing, w3 paid nothing. */ + const int64_t ids[] = { 1, 2, 3 }; + const int64_t owed[] = { 500000, 400, 900 }; + const int64_t paid[] = { 500000, 0, 0 }; + char err[256] = {0}; + assert(store_record_window_carry(s, ids, owed, paid, 3, err, sizeof err) == 2); + store_close(s); + + /* The miner the coinbase paid is owed nothing and must not appear: a row + * of zero would put a settled miner into a ledger of debts. */ + assert(scalar_path(path, "SELECT COUNT(*) FROM pps_credits") == 2); + assert(scalar_path(path, "SELECT COUNT(*) FROM pps_credits WHERE worker_id = 1") == 0); + assert(scalar_path(path, "SELECT accrued_sats FROM pps_credits WHERE worker_id = 2") == 400); + assert(scalar_path(path, "SELECT accrued_sats FROM pps_credits WHERE worker_id = 3") == 900); + printf(" ok test_only_unpaid_claims_are_recorded_as_owed\n"); +} + +/* Carry accumulates across blocks. That is the whole point: a miner too small + * to pay in one block becomes payable once enough blocks have passed. */ +static void test_carry_accumulates_across_blocks(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); + assert(store_record_share_addr(s, "w1", "addr_1", 1000, 1.0, + 0, NULL, 0, 0.0) == 0); + assert(store_flush(s) == 0); + + const int64_t ids[] = { 1 }; + const int64_t paid[] = { 0 }; + char err[256] = {0}; + for (int i = 0; i < 5; ++i) { + const int64_t owed[] = { 120 }; + assert(store_record_window_carry(s, ids, owed, paid, 1, + err, sizeof err) == 1); + } + store_close(s); + assert(scalar_path(path, "SELECT accrued_sats FROM pps_credits WHERE worker_id = 1") == 600); + printf(" ok test_carry_accumulates_across_blocks\n"); +} + +/* A partially-paid claim carries only the remainder, not the whole of it. + * Recording the full claim would have the pool owing money it already paid. */ +static void test_a_partly_paid_claim_carries_only_the_remainder(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); + assert(store_record_share_addr(s, "w1", "addr_1", 1000, 1.0, + 0, NULL, 0, 0.0) == 0); + assert(store_flush(s) == 0); + + const int64_t ids[] = { 1 }; + const int64_t owed[] = { 1000 }; + const int64_t paid[] = { 600 }; + char err[256] = {0}; + assert(store_record_window_carry(s, ids, owed, paid, 1, err, sizeof err) == 1); + store_close(s); + assert(scalar_path(path, "SELECT accrued_sats FROM pps_credits WHERE worker_id = 1") == 400); + printf(" ok test_a_partly_paid_claim_carries_only_the_remainder\n"); +} + +/* Defensive: an unknown worker id, and an empty window, must not write + * anything or fail. */ +static void test_the_carry_ledger_ignores_nothing_to_record(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); + char err[256] = {0}; + const int64_t ids[] = { 0 }; /* no such worker */ + const int64_t owed[] = { 900 }; + const int64_t paid[] = { 0 }; + assert(store_record_window_carry(s, ids, owed, paid, 1, err, sizeof err) == 0); + assert(store_record_window_carry(s, ids, owed, paid, 0, err, sizeof err) == 0); + assert(store_record_window_carry(s, NULL, owed, paid, 1, err, sizeof err) < 0); + store_close(s); + assert(scalar_path(path, "SELECT COUNT(*) FROM pps_credits") == 0); + printf(" ok test_the_carry_ledger_ignores_nothing_to_record\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(); @@ -1503,6 +1631,10 @@ int main(void) { test_pplns_distributes_the_window(); test_pplns_takes_the_operator_fee(); test_pplns_distributes_two_blocks_in_one_pass(); + test_the_carry_ledger_ignores_nothing_to_record(); + test_a_partly_paid_claim_carries_only_the_remainder(); + test_carry_accumulates_across_blocks(); + test_only_unpaid_claims_are_recorded_as_owed(); test_an_empty_window_returns_nothing_not_an_error(); test_a_window_wider_than_the_cap_says_so(); test_a_worker_with_no_address_is_left_out_of_the_split(); diff --git a/tests/test_stratum.c b/tests/test_stratum.c index a879c3e..e5306b6 100644 --- a/tests/test_stratum.c +++ b/tests/test_stratum.c @@ -2759,7 +2759,7 @@ static void test_pplns_coinbase_pays_every_miner_in_the_window(void) { { TEST_ADDR, 3000000000LL }, { TEST_ADDR2, 2000000000LL }, }; - CHECK(stratum_job_set_window(job, win, 2) == 0); + CHECK(stratum_job_set_window(job, win, NULL, 2) == 0); stratum_server_set_job(s, job, 1); stratum_conn_t *c = stratum_conn_new_for_test(s); From 383979b6fc7252b5366cf9585c2d172ed63dfed5 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 8 Sep 2026 15:55:16 +0200 Subject: [PATCH 09/36] pplns-coinbase: forfeit small claims to the operator instead of carrying them The carry ledger is gone. A claim the coinbase cannot pay -- below the payout floor, or with no room left in the byte budget -- now rides on the operator output permanently. It is income, not a debt: nothing records it and nothing settles it later. This is a policy decision, not an accounting convenience. The alternative was the carried balance this rail shipped with, which reintroduced exactly the custodial ledger the mode exists to delete: a debt, an off-chain record of it, and a settlement that can fail. Forfeiting keeps the property that the block IS the payment, at the price of a hard floor under who this pool is worth mining at. A miner too small to clear it earns nothing here however long it mines, and is better off solo mining, where it at least holds a lottery ticket. That is only a rule rather than a trap if the miner can see it, so the floor is now disclosed three ways: stated at startup, reported per template as the number of miners about to be excluded, and reported per block as the claims actually forfeited and for how much. The e2e asserts all three, and a mutation that reworded the startup line was caught by it. Also fixes a latent stall this made visible. attach_pplns_window() divided the template's coinbasevalue, which is the SUM of every coinbase output, while the builders check the payees against the single spendable output they replace. They agree whenever the commitments carry no value -- the only shape seen in practice -- but on a node where they did not, every render on every connection would be refused and the pool would stop publishing work with nothing but a repeated warning to explain it. coinbase_template_reward() now asks the transaction, and is asserted against the builder rather than a constant. - new pplns_payout_floor_sats (default 546, clamped up to the dust limit) - COINBASE_DUST_SATS promoted to coinbase.h; three copies of 546 were three chances to disagree - removes store_record_window_carry(), window_outcome_fn, and the per-job worker-id array that existed only to name who was owed - README, INSTALL, proxy.conf.example and docs/simplepool.html carry the fifth mode and say plainly that small claims are forfeited Verified: make test, make asan, all five regtest e2e suites, both node suites; six mutations of the new floor/reward logic all killed. --- INSTALL.md | 11 +- README.md | 66 +++++++-- docs/simplepool.html | 123 +++++++++++++---- proxy.conf.example | 25 +++- src/coinbase.c | 106 ++++++++++++--- src/coinbase.h | 85 ++++++++---- src/config.c | 13 ++ src/config.h | 12 ++ src/main.c | 119 ++++++++++------ src/store.c | 61 --------- src/store.h | 24 ---- src/stratum.c | 89 ++++++------ src/stratum.h | 22 +-- tests/test_coinbase.c | 196 ++++++++++++++++++++------- tests/test_config.c | 34 +++++ tests/test_pplns_coinbase_regtest.sh | 84 ++++++++---- tests/test_store.c | 132 ------------------ tests/test_stratum.c | 2 +- 18 files changed, 728 insertions(+), 476 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index b550f80..9a0fade 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -28,12 +28,21 @@ possibilities are called out clearly: - **`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. +- **`pool_mode = pplns-coinbase`** — the same accounting with no custody + at all: the block's coinbase pays the whole window directly, one output + per miner. No pool wallet, no payout worker, no maturity wait. + Usernames are Bitcoin addresses. **A miner whose share of a block is + worth less than `pplns_payout_floor_sats` (default 546) is not paid, + and the amount goes to the operator — it is not carried and not settled + later.** That is deliberate; see the mode's section in + [README.md](README.md#the-five-modes) and publish the floor to your + miners before you run it. 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 +(A sixth 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.) diff --git a/README.md b/README.md index 22fe786..ad4f8de 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,16 @@ 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 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. +It runs in five modes, which differ in who carries the variance and who holds +the money in between: **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; +**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; and **pplns-coinbase**, which is that +same PPLNS accounting with the custody removed — the block's own coinbase pays +the whole window directly, one output per miner. All five ship in this repo — +see [The five modes](#the-five-modes) below. Created by **Roberto Santacroce**. Canonical repository: . @@ -46,11 +48,11 @@ curl -fsSL https://raw.githubusercontent.com/LayerTwo-Labs/simplepool/main/scrip > audit every number — lives at [`docs/simplepool.html`](docs/simplepool.html). > Open it from disk or serve it next to the dashboard. -### The four modes +### The five modes -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: +This repository ships **all five**, selected by `pool_mode` in +`proxy.conf`. They differ in two independent things — who the coinbase pays, +and what a stratum username is: | `pool_mode` | coinbase pays | username | who carries the variance | | --- | --- | --- | --- | @@ -58,6 +60,7 @@ pays the miner or the pool, and what a stratum username is: | `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 | +| `pplns-coinbase` | **the whole window, directly** | 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 @@ -147,6 +150,45 @@ pays the miner or the pool, and what a stratum username is: separate rail knob, so the inconsistent configuration is unrepresentable rather than merely rejected. +- **`pool_mode = pplns-coinbase`** — the same PPLNS accounting as the two + rails above, with the custody taken out. There is no pool wallet, no + payout worker, no `pps_credits` row and no maturity wait: the block's own + coinbase pays the entire window directly, one output per miner, largest + claim first. A reorged block simply never paid, so there is nothing to + claw back. Username is a Bitcoin address. + + The window is snapshotted onto the job when the template is built, so the + coinbase pays the work that exists *now* rather than work from 100 blocks + ago. On a drivechain the coinbase comes from the CUSF enforcer, and its + BIP300/301 commitment `OP_RETURN`s are preserved byte-for-byte — only the + enforcer's own reward output is replaced, by the window. + + **Two limits, and both cost miners money rather than the pool:** + + - `coinbase_max_bytes` (default 1000) budgets the *whole serialized + coinbase*, commitments included, because that is what a rented-hashrate + marketplace measures when it decides a job is oversized. A production + coinbase-direct pool reports whole coinbases of 721–817 bytes paying up + to 16 miners, where the same 16 payouts cost 817 bytes against four + drivechain `OP_RETURN`s and 769 against three. A cap counted in outputs + cannot see that; a byte budget can. + - `pplns_payout_floor_sats` (default 546, the dust limit) is the minimum + a claim must be worth to get an output at all. + + **A claim that clears neither is forfeited to the operator. It is not + carried, not recorded, and not settled later.** That is a deliberate + policy and not a rounding artefact: there is nowhere to hold it, because + the payment *is* the block, and carrying it would rebuild exactly the + custodial ledger this mode exists to delete. The consequence is a hashrate + floor — a miner too small to clear it will mine here, submit valid shares, + and earn nothing indefinitely, which is strictly worse for them than solo + mining, where they at least hold a lottery ticket. + + Because that is a trap unless it is visible, the proxy states the floor at + startup, logs how many miners in the current window fall below it, and + reports per block how many claims were forfeited and for how much. **If + you run this mode, publish the floor on your pool page.** + 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 diff --git a/docs/simplepool.html b/docs/simplepool.html index 8616fd5..ee78ce0 100644 --- a/docs/simplepool.html +++ b/docs/simplepool.html @@ -267,14 +267,17 @@

simplepool

A single-binary stratum server in pure C11. It hands work to your ASICs, checks every submission itself, submits found blocks, and records the whole - thing in a SQLite file you are allowed to read. It runs in four modes, - which differ in who carries the variance — solo, where the + thing in a SQLite file you are allowed to read. It runs in five modes, + which differ in who carries the variance and who holds the money in + between — solo, where the miner who finds a block is paid in that block's own coinbase; pps-classic, where every accepted share earns a fixed, derivable amount and the operator absorbs the difference out of a reserve; - and pplns-thunder / pplns-btc, where a + pplns-thunder / pplns-btc, where a matured block is divided among the shares that produced it, so the pool - never owes more than it has just been paid. + never owes more than it has just been paid; and + pplns-coinbase, the same accounting with the custody + taken out — the block's own coinbase pays the whole window directly.

C11 · no runtime dependencies beyond libc, sqlite3, libcurl, hiredis @@ -292,7 +295,7 @@

simplepool

Contents
  1. What it is
  2. -
  3. The four modes
  4. +
  5. The five modes
  6. The stack
  7. Life of a share
  8. Dividing the search space
  9. @@ -369,7 +372,7 @@

    Why "share" and not "work unit"

    -

    The four modes

    +

    The five modes

    One config key — pool_mode — decides the shape of the coinbase, what a stratum username must be, when any off-chain balance moves, and @@ -449,10 +452,65 @@

    pool_mode = pplns-btc

    Needs
    bitcoind, and the enforcer with --enable-wallet
+ +
+

pool_mode = pplns-coinbase

+

+ The same accounting again, with the custody taken out. There is no pool + wallet, no payout worker, no ledger row and no maturity wait: the + block's own coinbase pays the whole window directly, one output per + miner, largest claim first. A reorged block simply never paid, so there + is nothing to claw back. +

+

+ The window is snapshotted onto the job when the template is built, so + the coinbase pays the work that exists now. On a drivechain + the coinbase comes from the enforcer and its BIP300/301 commitment + OP_RETURNs are preserved byte-for-byte — only the + enforcer's own reward output is replaced, by the window. +

+
+
Stratum username
your Bitcoin address, bc1q… or base58
+
Who gets paid
everyone in the window who clears the payout floor
+
When
in the block itself — there is no "when"
+
Variance
the miners'
+
Shares are
a claim on the next block this pool finds
+
Needs
bitcoind, or the enforcer for a drivechain
+
+
+ + +
+ pplns-coinbase forfeits small claims to the operator +

+ A coinbase is a fixed budget of bytes, and every payout spends some of + it. Two limits follow, and both cost miners money rather than the + pool: coinbase_max_bytes (default 1000) budgets the + whole serialized coinbase, commitments included, because that is what a + rented-hashrate marketplace measures when it refuses a job as oversized; + and pplns_payout_floor_sats (default 546, the dust limit) is + the least a claim must be worth to get an output at all. +

+

+ A claim that clears neither is forfeited to the operator — not + carried, not recorded, and not settled later. That is deliberate. + There is nowhere to hold it, because the payment is the block, + and carrying it would rebuild exactly the custodial ledger this mode + exists to delete. The consequence is a hashrate floor: a miner too small + to clear it will mine here, submit valid shares, and earn nothing + indefinitely — strictly worse for them than solo mining, where they at + least hold a lottery ticket. +

+

+ Because that is a trap unless it is visible, the proxy states the floor + at startup, reports how many miners in the current window fall below it, + and says per block how many claims were forfeited and for how much. + If you run this mode, publish the floor on your pool page. +

- Why a fourth mode at all + Why the PPLNS modes exist at all

PPS prices a share the moment it arrives, whether or not it ever becomes a block. Somebody has to fund the gap between what has been promised and @@ -473,46 +531,57 @@

pool_mode = pplns-btc

- + - + + - + + - + - + + - + + - + - + - + + - + - + + - + + + + + - + + - +
 solopps-classicpplns-thunderpplns-btc
 solopps-classicpplns-thunderpplns-btcpplns-coinbase
Coinbase outputs miner's address + operator fee pool_btc_address + operator feepool_btc_address + operator fee
pool_btc_address + operator feeone per miner in the window + operator
Per-connection coinbase yes — each miner's cb1/cb2 pay that miner no — every miner's coinbase pays the poolno — every miner's coinbase pays the pool
no — every miner's coinbase pays the poolno — every miner's coinbase pays the whole window
Stratum usernameBitcoin addressThunder addressThunder addressBitcoin address
Thunder addressBitcoin addressBitcoin address
Off-chain accountingnonepps_creditspps_credits, same table
pps_credits, same tablenone — the block is the ledger
When a balance movesnever — the coinbase is the payment as each share arriveswhen a block matures, 100 deep
when a block matures, 100 deepnever — the coinbase is the payment
Paid for work that found nothingnoyesno
no
Transaction fees sharedyes, to the finderno — subsidy-derived rateyes, to the window
yes, to the window
Pool custodies BTCneveryes, between mining and deposityes, between mining and deposityes, in the enforcer wallet
yes, between mining and deposityes, in the enforcer walletnever
Operator reserve needednoneyes — measured in block rewardsnone
none
Payout assetBTC, on the mainchainBTC on Thunder, a BIP300 sidechainBTC on ThunderBTC, on the mainchain
BTC on ThunderBTC, on the mainchainBTC, on the mainchain
Payout workernot installedsimplepool-payout.service simplepool-payout.servicesame, with PAYOUT_RAIL=btc
same, with PAYOUT_RAIL=btcnot installed
A claim too small to paycannot ariseaccrues until the payout worker can batch itforfeited to the operator, permanently
Miner's incomelumpy and rare, but completesmooth and proportionalproportional, but only when the pool finds a block
proportional, but only when the pool finds a blockthe same, above the payout floor — nothing below it
Who eats bad luckthe minerthe pool operatorthe miners, together
the miners, together
- A fifth mode existed and was removed + A sixth mode existed and was removed

pool_mode = pps put a BIP300 drivechain deposit directly in each coinbase, so the pool would never custody BTC at all. It does not @@ -1843,13 +1912,21 @@

Configuration

KeyDefaultMeaning pool_modesolo - solo, pps-classic, pplns-thunder or pplns-btc. + solo, pps-classic, pplns-thunder, pplns-btc or pplns-coinbase. Decides the coinbase shape, what a username must be, and when a balance moves. Bare pplns is refused: it does not say which rail pays. pplns_window_diff_multiple2.0 PPLNS only. Window size as a multiple of the CURRENT network difficulty, so it self-scales across retargets. Must be > 0; below 1.0 a block pays out across less work than it took to find, which rewards pool hopping, and the proxy warns. + coinbase_max_bytes1000 + pplns-coinbase only. Byte budget for the WHOLE serialized coinbase, + commitments included — a rented-hashrate marketplace measures bytes, and drivechain + OP_RETURNs spend them before any payout does. Minimum 200. + pplns_payout_floor_sats546 + pplns-coinbase only. A claim worth less than this is not paid, + and is forfeited to the operator — nothing is carried and nothing settles + later. Clamped up to 546, the dust limit. Disclose it to your miners. operator_addressRequired. Receives the fee_bps cut. The proxy refuses to start without it. fee_bps100 diff --git a/proxy.conf.example b/proxy.conf.example index fd417ab..8c163bb 100644 --- a/proxy.conf.example +++ b/proxy.conf.example @@ -228,10 +228,31 @@ pool_mode = solo # cannot see that; this can, because the commitments are simply part of what # has already been spent. # -# Whatever does not fit is not lost — it carries, and the smallest claims are -# the ones that wait. +# Whatever does not fit is forfeited to the operator — see +# pplns_payout_floor_sats below. # coinbase_max_bytes = 1000 +# pplns-coinbase — the payout floor, in satoshis. A miner whose share of a +# block comes to less than this is NOT PAID. The money goes to the operator +# output, there is no ledger entry, and it is not settled later. +# +# This is a deliberate policy and it needs stating to your miners, because it +# is the one place this pool is harsher than a custodial one. A custodial +# pool can hold a tiny balance until it is worth a transaction. Coinbase-direct +# has nowhere to hold it: the payment IS the block, so the only alternatives +# are to pay an output that costs more in bytes than it is worth, or to carry +# a debt against a block that may never come. We do neither. +# +# The practical effect is a hashrate floor. With a 3.125 BTC block and a 2x +# window, a miner needs roughly a 546/312500000 share of the window — about +# 0.0002% — to clear the default. Anyone below that will mine here, produce +# valid shares, and receive nothing, which is worse for them than solo mining +# where they at least hold a lottery ticket. Say so on your pool page. +# +# Raising it makes blocks cheaper in bytes and the floor harsher. Clamped up +# to 546 (the dust limit) — no smaller output is relayable anyway. +# pplns_payout_floor_sats = 546 + # 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/coinbase.c b/src/coinbase.c index bfcc532..0a1e4bb 100644 --- a/src/coinbase.c +++ b/src/coinbase.c @@ -500,7 +500,6 @@ int coinbase_network_is_mainnet(const char *network) { /* Bitcoin's standard relay dust threshold for legacy outputs. Below this * the operator fee output would not be relayed; we collapse to a single * miner-only output in that case. */ -#define COINBASE_DUST_SATS 546 void coinbase_parts_free(coinbase_parts_t *p) { if (!p) return; @@ -753,9 +752,9 @@ static size_t out_ser_size(size_t spk_len) { /* Payees plus the operator. */ #define CB_MAX_REPL_OUTS (COINBASE_MAX_PAYOUT_OUTPUTS + 1) -/* Turn a window into concrete outputs: fee off the top, dust and the output - * cap applied largest-first, and whatever cannot be paid folded onto the - * operator as carry. +/* Turn a window into concrete outputs: fee off the top, the payout floor and + * the byte budget applied largest-first, and whatever cannot be paid + * forfeited onto the operator output. * * Shared by the from-scratch and from-template builders precisely so the two * cannot drift. A pool mining a drivechain template and one mining plain @@ -766,6 +765,7 @@ static int resolve_window_outputs(int64_t value_sats, const coinbase_payee_t *payees, size_t n_payees, const char *operator_address, int fee_bps, size_t max_coinbase_bytes, size_t fixed_bytes, + int64_t payout_floor_sats, cb_repl_out_t *out, size_t cap, size_t *out_n, coinbase_window_result_t *res, char *errbuf, size_t errlen) { @@ -782,6 +782,10 @@ static int resolve_window_outputs(int64_t value_sats, return -1; } if (max_coinbase_bytes == 0) max_coinbase_bytes = COINBASE_DEFAULT_MAX_BYTES; + /* Never below the relay dust limit, whatever the operator configured: an + * output under it would not be relayed, so "paying" it pays nobody. */ + if (payout_floor_sats < COINBASE_DUST_SATS) + payout_floor_sats = COINBASE_DUST_SATS; /* What is left for payouts once everything that is not a payout has been * paid for: the transaction envelope, the scriptSig, the operator output * and — the term that actually binds on a drivechain pool — the @@ -834,14 +838,14 @@ static int resolve_window_outputs(int64_t value_sats, size_t n = 0; size_t payout_bytes = 0; - int64_t carry = 0; + int64_t forfeited = 0; for (size_t k = 0; k < n_payees; ++k) { const coinbase_payee_t *pe = &payees[rank[k].idx]; - if (pe->sats < COINBASE_DUST_SATS) { - r.dropped_dust++; carry += pe->sats; continue; + if (pe->sats < payout_floor_sats) { + r.dropped_below_floor++; forfeited += pe->sats; continue; } if (n + 1 >= cap) { /* storage, not policy */ - r.dropped_capped++; carry += pe->sats; continue; + r.dropped_capped++; forfeited += pe->sats; continue; } /* Resolve first: an output's cost depends on its address type, and a * P2TR payout is 43 bytes against a P2WPKH one's 31. Budgeting at a @@ -855,14 +859,12 @@ static int resolve_window_outputs(int64_t value_sats, if (payout_bytes + cost > payout_budget) { /* No room. Keep going rather than breaking: a later payee may be * a cheaper address type and still fit, and dropping it would - * carry money that could have been paid. */ - r.dropped_capped++; carry += pe->sats; continue; + * forfeit money that could have been paid. */ + r.dropped_capped++; forfeited += pe->sats; continue; } payout_bytes += cost; out[n].sats = pe->sats; r.paid_sats += pe->sats; - if (rank[k].idx < COINBASE_MAX_PAYOUT_OUTPUTS) - r.paid_per_payee[rank[k].idx] = pe->sats; n++; r.paid_count++; } free(rank); @@ -871,20 +873,22 @@ static int resolve_window_outputs(int64_t value_sats, set_err(errbuf, errlen, "no payee fits: %zu-byte coinbase budget leaves %zu bytes for " "payouts after %zu bytes of transaction and commitments, and " - "nothing clears the %d-sat dust limit", + "nothing clears the %lld-sat payout floor", max_coinbase_bytes, payout_budget, fixed_bytes, - COINBASE_DUST_SATS); + (long long)payout_floor_sats); return -1; } - /* Carry rides on the operator output, because it has to ride somewhere: - * value not paid out is value destroyed. */ - int64_t operator_out = fee_sats + carry; + /* Forfeits ride on the operator output, because value not paid out is + * value destroyed — a coinbase paying less than it may does not leave the + * remainder anywhere. They are the operator's income, not a debt: see + * coinbase_window_result_t. */ + int64_t operator_out = fee_sats + forfeited; if (operator_out > 0 && !has_operator) { if (!operator_address || !operator_address[0]) { set_err(errbuf, errlen, "%lld sats could not be paid to the window and there is no " - "operator_address to carry them", (long long)operator_out); + "operator_address to receive them", (long long)operator_out); return -1; } if (coinbase_address_to_script(operator_address, op.spk, sizeof op.spk, @@ -898,7 +902,7 @@ static int resolve_window_outputs(int64_t value_sats, } r.fee_sats = fee_sats; - r.carry_sats = carry; + r.forfeited_sats = forfeited; if (out_n) *out_n = n; if (res) *res = r; return 0; @@ -911,6 +915,7 @@ int coinbase_build_window(uint32_t height, int64_t value_sats, const char *coinbase_tag, size_t extranonce1_size, size_t extranonce2_size, size_t max_coinbase_bytes, + int64_t payout_floor_sats, coinbase_parts_t *out, coinbase_window_result_t *res, char *errbuf, size_t errlen) { @@ -936,8 +941,8 @@ int coinbase_build_window(uint32_t height, int64_t value_sats, + 4 + 3 /* output-count varint, conservatively */ + 4; if (wc_probe_len) fixed += out_ser_size(wc_probe_len); /* Reserve the operator output whether or not it turns out to be needed: - * carry lands on it, and carry is exactly what happens when the budget - * bites. Conservative by ~31 bytes in the rare case it is absent. */ + * forfeits land on it, and forfeits are exactly what happens when the + * budget bites. Conservative by ~31 bytes if it is absent. */ if (operator_address && operator_address[0]) fixed += out_ser_size(34); /* Same resolver the template builder uses: the split is one rule, in one @@ -945,7 +950,8 @@ int coinbase_build_window(uint32_t height, int64_t value_sats, cb_repl_out_t repl[CB_MAX_REPL_OUTS]; size_t n_repl = 0; if (resolve_window_outputs(value_sats, payees, n_payees, operator_address, - fee_bps, max_coinbase_bytes, fixed, repl, + fee_bps, max_coinbase_bytes, fixed, + payout_floor_sats, repl, CB_MAX_REPL_OUTS, &n_repl, res, errbuf, errlen) < 0) { return -1; @@ -1305,6 +1311,7 @@ typedef struct { const char *operator_address; int fee_bps; size_t max_coinbase_bytes; + int64_t payout_floor_sats; coinbase_window_result_t *res; } repl_window_ctx_t; @@ -1315,6 +1322,7 @@ static int repl_window(void *vctx, int64_t reward, size_t fixed_bytes, return resolve_window_outputs(reward, c->payees, c->n_payees, c->operator_address, c->fee_bps, c->max_coinbase_bytes, fixed_bytes, + c->payout_floor_sats, out, cap, out_n, c->res, errbuf, errlen); } @@ -1357,6 +1365,7 @@ int coinbase_build_window_from_template(const char *coinbase_tx_hex, size_t extranonce1_size, size_t extranonce2_size, size_t max_coinbase_bytes, + int64_t payout_floor_sats, coinbase_parts_t *out, int *out_has_witness, coinbase_window_result_t *res, @@ -1373,6 +1382,7 @@ int coinbase_build_window_from_template(const char *coinbase_tx_hex, ctx.operator_address = operator_address; ctx.fee_bps = fee_bps; ctx.max_coinbase_bytes = max_coinbase_bytes; + ctx.payout_floor_sats = payout_floor_sats; ctx.res = res; return build_from_template_impl(coinbase_tx_hex, repl_window, &ctx, coinbase_tag, extranonce1_size, @@ -1392,6 +1402,58 @@ int coinbase_build_window_from_template(const char *coinbase_tx_hex, * * Parses only far enough to walk the output list. Returns 0 on success, * negative on malformed input; counts are untouched on failure. */ +/* The reward the template's coinbase actually pays. See coinbase.h for why a + * caller must divide this rather than the node's `coinbasevalue` field. + * + * Implemented on top of the replacement parser rather than a second walk of + * the transaction, so it cannot disagree with the builder about which output + * is the spendable one -- disagreeing there is the entire failure this + * function exists to prevent. */ +static int cb_reward_probe(void *ctx, int64_t reward_sats, size_t fixed_bytes, + cb_repl_out_t *out, size_t cap, size_t *out_n, + char *errbuf, size_t errlen); + +int coinbase_template_reward(const char *coinbase_tx_hex, int64_t *out_sats) { + if (!coinbase_tx_hex || !out_sats) return -1; + int spendable = 0; + if (coinbase_count_outputs(coinbase_tx_hex, &spendable, NULL) < 0) return -1; + if (spendable != 1) return -1; + + int64_t reward = -1; + coinbase_parts_t throwaway; + char err[256] = {0}; + /* The replacement machinery hands the callback the reward it computed; + * we keep the number and put the output back unchanged. */ + if (build_from_template_impl(coinbase_tx_hex, cb_reward_probe, &reward, + NULL, 4, 4, &throwaway, NULL, + err, sizeof err) < 0) { + return -1; + } + coinbase_parts_free(&throwaway); + if (reward < 0) return -1; + *out_sats = reward; + return 0; +} + +/* Records the reward and re-emits the output the builder would have replaced, + * so the throwaway coinbase this produces is byte-identical to the input. */ +static int cb_reward_probe(void *ctx, int64_t reward_sats, size_t fixed_bytes, + cb_repl_out_t *out, size_t cap, size_t *out_n, + char *errbuf, size_t errlen) { + (void)fixed_bytes; + if (!ctx || !out || cap < 1) { + set_err(errbuf, errlen, "reward probe: bad arg"); + return -1; + } + *(int64_t *)ctx = reward_sats; + /* An OP_TRUE output: never broadcast, only measured. */ + out[0].sats = reward_sats; + out[0].spk[0] = 0x51; + out[0].spk_len = 1; + if (out_n) *out_n = 1; + return 0; +} + int coinbase_count_outputs(const char *tx_hex, int *spendable_out, int *op_return_out) { if (!tx_hex) return -1; diff --git a/src/coinbase.h b/src/coinbase.h index 59224b7..428bb52 100644 --- a/src/coinbase.h +++ b/src/coinbase.h @@ -78,6 +78,12 @@ typedef struct { * marketplace an operator is selling to, not to us. */ #define COINBASE_DEFAULT_MAX_BYTES 1000 +/* Below this an output is not relayable, so it is the floor under every other + * floor. In the header rather than coinbase.c because main.c reports the + * effective payout floor to the operator and config.c documents it, and three + * copies of 546 is three chances to disagree. */ +#define COINBASE_DUST_SATS 546 + /* Array bound only. The byte budget is what actually decides how many miners * are paid; this exists so the builders can use fixed-size storage, and is set * far above anything the budget will admit. */ @@ -85,32 +91,40 @@ typedef struct { /* What the builder actually managed to pay, and what it could not. * - * `carry_sats` is the honest part. A payee below the dust limit, or past the - * output cap, cannot be paid in THIS coinbase — but its value cannot simply - * vanish either: a coinbase that pays out less than it is allowed forfeits - * the difference to nobody. So the shortfall is added to the operator output - * and reported here, which means the pool is holding it and owes it. - * - * That is the cost the design has to own: coinbase-direct removes custody for - * everyone the block can pay, and replaces it with a small, bounded, - * disclosable balance for everyone it cannot. It is not "zero custody"; it is - * custody proportional to dust, and the number is right here rather than - * implied. */ + * `forfeited_sats` is the honest part. A payee below the payout floor, or + * past the byte budget, cannot be paid in THIS coinbase, and its value cannot + * simply vanish either: a coinbase that pays out less than it is allowed + * forfeits the difference to nobody. So the shortfall goes to the operator + * output — and it stays there. + * + * That is the cost the design has to own, and it is a cost borne by the + * smallest miners rather than by the pool. The alternative was a carried + * balance, which is the custodial ledger this mode exists to delete: it + * reintroduces a debt, an off-chain record of it, and a settlement that can + * fail. Forfeiting instead keeps the property that the block IS the payment, + * at the price of a hard floor under who this pool is worth mining at. The + * number is right here so it can be disclosed rather than discovered. */ typedef struct { size_t paid_count; /* payees given an output */ int64_t paid_sats; /* summed across those outputs */ - size_t dropped_dust; /* payees below COINBASE_DUST_SATS */ - size_t dropped_capped; /* payees the byte budget had no room for */ - int64_t carry_sats; /* owed to the dropped, paid to the operator */ - int64_t fee_sats; /* the operator's actual fee, excluding carry */ - /* What each payee actually received, indexed as the CALLER passed them — - * not in the largest-first order the builder pays in. 0 means the payee - * carried: it was below the floor, or the byte budget had no room. + size_t dropped_below_floor; /* payees under payout_floor_sats */ + size_t dropped_capped; /* payees the byte budget had no room for */ + /* Claims the coinbase could not pay, which go to the operator. * - * Without this the carry is a single number and nobody knows whose it is. - * A pool that cannot say which miner is owed the dust is not running a - * ledger, it is just keeping the money. */ - int64_t paid_per_payee[COINBASE_MAX_PAYOUT_OUTPUTS]; + * FORFEITED, not owed. This is a deliberate policy choice and not an + * accounting convenience: a coinbase-direct pool cannot pay an amount too + * small to be an economical output, and carrying it creates exactly the + * custodial balance the mode exists to remove. So a claim below the + * payout floor is not paid, is not remembered, and is not a debt — it + * becomes operator income. + * + * The consequence is real and has to be disclosed rather than discovered: + * a miner whose share never reaches the floor earns nothing, however long + * it mines. That is the intended incentive — a miner that small is better + * off mining solo — but it is only a rule rather than a trap if the miner + * can see it, which is why the floor is logged at startup and per block. */ + int64_t forfeited_sats; + int64_t fee_sats; /* the operator's fee, excluding forfeits */ } coinbase_window_result_t; @@ -125,9 +139,13 @@ typedef struct { * fee_bps split every other builder applies. A caller whose arithmetic does * not add up is refused rather than silently underpaying the block. * - * Payees are paid largest first, so the byte budget and the dust limit fall - * on the smallest claims — the ones for whom waiting a block costs least, and - * whose carried balance is smallest. + * Payees are paid largest first, so the byte budget and the payout floor fall + * on the smallest claims. Those are forfeited to the operator, not carried: + * see coinbase_window_result_t. + * + * `payout_floor_sats` is clamped UP to COINBASE_DUST_SATS — below the dust + * limit an output is not relayable, so there is no floor lower than that to + * have. * * `max_coinbase_bytes` is the whole serialized coinbase, commitments and all, * not just the payouts. 0 means COINBASE_DEFAULT_MAX_BYTES. @@ -140,6 +158,7 @@ int coinbase_build_window(uint32_t height, int64_t value_sats, const char *coinbase_tag, size_t extranonce1_size, size_t extranonce2_size, size_t max_coinbase_bytes, + int64_t payout_floor_sats, coinbase_parts_t *out, coinbase_window_result_t *res, char *errbuf, size_t errlen); @@ -186,6 +205,21 @@ void coinbase_parts_free(coinbase_parts_t *p); * enforcer (plus the mandatory BIP300/301 commitments), which is what tells an * observer whether a sidechain can be merge-mined into these blocks. * Returns 0 ok, negative on malformed input. */ +/* The reward a server-provided coinbasetxn actually pays, in sats: the value + * of its single spendable output, which is the one the window replaces. + * + * A caller splitting a window has to divide THIS number, not the template's + * `coinbasevalue`. The two normally agree, but nothing makes them: the field + * is what the node says the block may pay, and the transaction is what its + * coinbase does pay. When they disagree the builders refuse the split -- the + * payees no longer sum to the reward -- and since that happens per job, on + * every connection, a pool would simply stop publishing work with a warning + * per render and no single cause to find. + * + * Returns 0 ok, negative if the tx is malformed or does not have exactly one + * spendable output (in which case there is no single reward to speak of). */ +int coinbase_template_reward(const char *coinbase_tx_hex, int64_t *out_sats); + /* coinbase_build_window(), but replacing the single spendable output of a * server-provided coinbasetxn instead of building one from scratch. * @@ -210,6 +244,7 @@ int coinbase_build_window_from_template(const char *coinbase_tx_hex, size_t extranonce1_size, size_t extranonce2_size, size_t max_coinbase_bytes, + int64_t payout_floor_sats, coinbase_parts_t *out, int *out_has_witness, coinbase_window_result_t *res, diff --git a/src/config.c b/src/config.c index 3afdc44..ce16031 100644 --- a/src/config.c +++ b/src/config.c @@ -1,5 +1,6 @@ #define _POSIX_C_SOURCE 200809L #include "config.h" +#include "coinbase.h" #include "log.h" #include @@ -68,6 +69,7 @@ void proxy_config_defaults(proxy_config_t *cfg) { snprintf(cfg->pool_mode, sizeof cfg->pool_mode, "%s", "solo"); cfg->pplns_window_diff_multiple = 2.0; cfg->coinbase_max_bytes = 1000; + cfg->pplns_payout_floor_sats = COINBASE_DUST_SATS; cfg->pool_btc_address[0] = '\0'; cfg->pps_sats_per_diff = 0.0; cfg->pps_min_network_difficulty = 0.0; @@ -284,6 +286,7 @@ int proxy_config_load(const char *path, proxy_config_t *cfg, 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, "coinbase_max_bytes") == 0) cfg->coinbase_max_bytes = atoi(v); + else if (strcmp(k, "pplns_payout_floor_sats") == 0) cfg->pplns_payout_floor_sats = strtoll(v, NULL, 10); 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); @@ -391,6 +394,16 @@ int proxy_config_load(const char *path, proxy_config_t *cfg, cfg->coinbase_max_bytes); return -14; } + /* A negative floor is a typo, not a policy. Zero is legitimate -- it + * means "pay anything the dust limit allows" -- so only reject below + * that, and let coinbase.c do the clamp up to the dust limit so the + * floor has one definition. */ + if (cfg->pplns_payout_floor_sats < 0) { + set_err(errbuf, errlen, + "config: 'pplns_payout_floor_sats' = %lld must be >= 0", + (long long)cfg->pplns_payout_floor_sats); + return -15; + } } if (mode_pplns) { if (!(cfg->pplns_window_diff_multiple > 0.0)) { diff --git a/src/config.h b/src/config.h index 8a1e238..9883594 100644 --- a/src/config.h +++ b/src/config.h @@ -128,6 +128,18 @@ typedef struct { * headroom on that. 0 = COINBASE_DEFAULT_MAX_BYTES. */ int coinbase_max_bytes; /* default 1000 */ + /* pplns-coinbase: a claim worth less than this is not paid at all. It is + * forfeited to the operator output, and there is no ledger entry and no + * later settlement -- see the long note in proxy.conf.example. + * + * Deliberate policy, not a rounding artefact: coinbase-direct pays out of + * the block itself, so every extra output is bytes an operator may not + * have. Rather than carry a debt no one can see, the floor is stated up + * front and a miner too small to clear it is better off solo mining. + * Clamped up to COINBASE_DUST_SATS (546); below that no output is + * relayable anyway. */ + int64_t pplns_payout_floor_sats; /* default 546 (dust) */ + /* pooled modes: coinbase pays this BTC address (P2WPKH/P2PKH/P2SH) for * the net-of-fee reward. Required when pool_mode = pps-classic; * ignored otherwise. */ diff --git a/src/main.c b/src/main.c index 316642b..537ef63 100644 --- a/src/main.c +++ b/src/main.c @@ -180,40 +180,6 @@ static double effective_pps_rate(const proxy_config_t *cfg, return pps_rate_from_template(value_sats, net_diff, cfg->fee_bps); } -/* Build a job from a freshly fetched template. The coinbase is rendered - * per-connection inside stratum.c (each miner pays their own address), - * so we only pass template-level data here. */ -/* What a found block's coinbase actually paid, and what it carried. - * - * Only the carried part is recorded. A claim the coinbase paid is settled on - * chain and has no business in a ledger of what is owed; a claim it could not - * pay rode on the operator output, which means the operator is holding it. */ -static void on_window_outcome_cb(void *ctx, const char *block_hash, - const int64_t *worker_ids, - const int64_t *owed_sats, - const int64_t *paid_sats, size_t n) { - server_ctx_t *s = (server_ctx_t *)ctx; - if (!s || !s->store) return; - char werr[256] = {0}; - int rc = store_record_window_carry(s->store, worker_ids, owed_sats, - paid_sats, n, werr, sizeof werr); - if (rc < 0) { - LOG_WARN("pplns-coinbase: could not record carried claims for block " - "%.16s: %s — the operator is holding money the ledger does " - "not know about", block_hash ? block_hash : "?", werr); - return; - } - if (rc > 0) { - int64_t carried = 0; - for (size_t i = 0; i < n; ++i) carried += owed_sats[i] - paid_sats[i]; - LOG_INFO("pplns-coinbase: block %.16s paid %zu miner(s) directly; " - "%d claim(s) totalling %lld sats were below the floor or out " - "of coinbase room and are carried", - block_hash ? block_hash : "?", n - (size_t)rc, rc, - (long long)carried); - } -} - /* Snapshot the PPLNS window onto a freshly built job, for pplns-coinbase. * * The window is taken from the template that is about to go out, so the @@ -283,12 +249,42 @@ static int attach_pplns_window(store_t *store, const proxy_config_t *cfg, /* Split the payable amount by difficulty. The fee comes off the top the * same way every other builder does it, so it is computed here too -- * the payees have to sum to exactly what is left, or the builder refuses - * rather than letting the block forfeit the difference. */ + * rather than letting the block forfeit the difference. + * + * On a server-provided coinbase the number to divide is what that + * transaction's spendable output actually pays, NOT the template's + * coinbase_value_sats, which is the SUM of every output. They agree + * whenever the commitments carry no value, which is the only shape seen + * in practice -- but "agree in practice" is exactly the kind of premise + * that fails on somebody else's node, and the failure has no floor: the + * payees would no longer sum to the reward, so the builder would refuse + * every render, on every connection, and the pool would stop publishing + * work with nothing but a repeated warning to explain it. + * + * Ask the transaction instead, and the premise cannot fail. If it cannot + * be asked, refuse THIS template with a reason rather than mining a split + * that the builder will reject a thousand times over. */ int64_t value = t->coinbase_value_sats; + if (t->coinbasetxn_hex) { + int64_t from_tx = 0; + if (coinbase_template_reward(t->coinbasetxn_hex, &from_tx) < 0) { + LOG_WARN("pplns-coinbase: the template's coinbase does not have a " + "single spendable output to replace — no window can be " + "paid from it, so this template is skipped"); + return -1; + } + if (from_tx != value) { + LOG_WARN("pplns-coinbase: template says coinbasevalue=%lld but its " + "coinbase pays %lld — splitting what the transaction " + "actually pays", + (long long)value, (long long)from_tx); + } + value = from_tx; + } int64_t fee = 0; if (cfg->operator_address[0] && cfg->fee_bps > 0) { int64_t f = (value * (int64_t)cfg->fee_bps) / 10000; - if (f >= 546) fee = f; /* COINBASE_DUST_SATS */ + if (f >= COINBASE_DUST_SATS) fee = f; } int64_t payable = value - fee; if (payable <= 0) { @@ -298,10 +294,8 @@ static int attach_pplns_window(store_t *store, const proxy_config_t *cfg, } coinbase_payee_t payees[COINBASE_MAX_PAYOUT_OUTPUTS]; - int64_t worker_ids[COINBASE_MAX_PAYOUT_OUTPUTS]; int64_t assigned = 0; for (size_t i = 0; i < n; ++i) { - worker_ids[i] = win[i].worker_id; payees[i].address = win[i].payout_address; payees[i].sats = (int64_t)((double)payable * (win[i].difficulty / total)); assigned += payees[i].sats; @@ -313,15 +307,50 @@ static int attach_pplns_window(store_t *store, const proxy_config_t *cfg, * A handful of satoshis, to the miner with the strongest claim on them. */ if (assigned < payable) payees[0].sats += payable - assigned; - if (stratum_job_set_window(job, payees, worker_ids, n) < 0) { + if (stratum_job_set_window(job, payees, n) < 0) { LOG_WARN("pplns-coinbase: could not attach the window to the job"); return -1; } + + /* Warn about miners the floor will exclude, BEFORE a block makes it real. + * + * The per-block line in stratum.c reports what was forfeited after the + * fact; this reports who is about to be, which is the only form an + * operator can act on -- by telling those miners, or by lowering the + * floor. Rate-limited to changes in the count, because it is recomputed + * on every template and a steady state is not news -- the static needs no + * lock, this runs only on the template-poller thread. + * + * The clamp mirrors coinbase.c's: below the dust limit there is no floor + * to have, so reporting an unclamped one would understate who loses. */ + int64_t floor_sats = cfg->pplns_payout_floor_sats < COINBASE_DUST_SATS + ? COINBASE_DUST_SATS : cfg->pplns_payout_floor_sats; + size_t below = 0; + for (size_t i = 0; i < n; ++i) if (payees[i].sats < floor_sats) below++; + static size_t last_below = (size_t)-1; + if (below != last_below) { + last_below = below; + if (below > 0) { + LOG_INFO("pplns-coinbase: %zu of %zu miner(s) in the window are " + "below the %lld-sat payout floor and will earn NOTHING " + "from the next block — their share is forfeited to the " + "operator, not carried. Tell them, or lower " + "pplns_payout_floor_sats.", + below, n, (long long)floor_sats); + } else { + LOG_INFO("pplns-coinbase: every miner in the window clears the " + "%lld-sat payout floor", (long long)floor_sats); + } + } + LOG_DEBUG("pplns-coinbase: window of %zu miner(s), %.2f difficulty, " "paying %lld sats", n, total, (long long)payable); return 0; } +/* Build a job from a freshly fetched template. The coinbase is rendered + * per-connection inside stratum.c (each miner pays their own address), + * so we only pass template-level data here. */ static stratum_job_t *build_job_from_template(const proxy_config_t *cfg, const bitcoind_template_t *t, char *errbuf, size_t errlen) { @@ -1328,8 +1357,18 @@ int main(int argc, char **argv) { stcfg.coinbase_pays_pool = mode_pps_classic || mode_pplns_thunder || mode_pplns_btc; stcfg.coinbase_pays_window = mode_pplns_cb; - stcfg.on_window_outcome = mode_pplns_cb ? on_window_outcome_cb : NULL; + if (mode_pplns_cb) { + /* Say the policy out loud on every start. It is the one place this + * pool is harsher than a custodial one, and an operator who never + * saw it stated cannot disclose it to the miners it costs. */ + LOG_INFO("pplns-coinbase: payout floor %lld sats — a miner whose " + "share of a block is worth less than that is NOT PAID, and " + "the amount goes to the operator. Nothing is carried and " + "nothing settles later. Publish this on your pool page.", + (long long)cfg.pplns_payout_floor_sats); + } stcfg.max_coinbase_bytes = (size_t)cfg.coinbase_max_bytes; + stcfg.payout_floor_sats = cfg.pplns_payout_floor_sats; 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); diff --git a/src/store.c b/src/store.c index 9bcd002..1b4bc2f 100644 --- a/src/store.c +++ b/src/store.c @@ -1498,67 +1498,6 @@ int store_pplns_window(store_t *s, double window_diff, return (int)n; } -int store_record_window_carry(store_t *s, - const int64_t *worker_ids, - const int64_t *owed_sats, - const int64_t *paid_sats, - size_t n, char *errbuf, size_t errlen) -{ - if (!s || !s->db || !worker_ids || !owed_sats || !paid_sats) { - if (errbuf && errlen) snprintf(errbuf, errlen, "bad arg"); - return -1; - } - if (n == 0) return 0; - - static const char *Q = - "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"; - - /* One transaction for the whole block: either every carried claim is - * recorded or none is. A partial record is the failure that cannot be - * repaired by running again, because there is no second chance to see - * this coinbase. */ - if (sqlite3_exec(s->db, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) { - if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); - return -1; - } - sqlite3_stmt *st = NULL; - if (sqlite3_prepare_v2(s->db, Q, -1, &st, NULL) != SQLITE_OK) { - if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); - sqlite3_exec(s->db, "ROLLBACK", NULL, NULL, NULL); - atomic_fetch_add(&s->pg_errors, 1); - return -2; - } - int credited = 0, ok = 1; - sqlite3_int64 now = (sqlite3_int64)time(NULL); - for (size_t i = 0; i < n; ++i) { - int64_t carried = owed_sats[i] - paid_sats[i]; - /* Paid in full on chain: nothing is owed, so nothing is recorded. - * Writing a zero row would put every miner in a ledger of debts the - * pool does not have. */ - if (carried <= 0 || worker_ids[i] <= 0) continue; - sqlite3_bind_int64(st, 1, worker_ids[i]); - sqlite3_bind_int64(st, 2, carried); - sqlite3_bind_int64(st, 3, now); - if (sqlite3_step(st) != SQLITE_DONE) { ok = 0; } - sqlite3_reset(st); - if (!ok) break; - credited++; - } - sqlite3_finalize(st); - if (!ok) { - sqlite3_exec(s->db, "ROLLBACK", NULL, NULL, NULL); - if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); - atomic_fetch_add(&s->pg_errors, 1); - return -2; - } - sqlite3_exec(s->db, "COMMIT", NULL, NULL, NULL); - return credited; -} - 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 4b30bbc..a223552 100644 --- a/src/store.h +++ b/src/store.h @@ -198,30 +198,6 @@ int store_pplns_window(store_t *s, double window_diff, size_t *out_n, double *out_total_diff, int *out_truncated, char *errbuf, size_t errlen); -/* Record what a found block's coinbase paid, and what it did not. - * - * pplns-coinbase pays miners in the block itself, so most of the ledger the - * other rails keep is unnecessary — with one exception. A claim too small to - * put in a coinbase output, or one the byte budget had no room for, is not - * paid and is not lost: it rides on the operator output, which means the - * operator is holding it and owes it. - * - * That debt is recorded here, in the same pps_credits table the other rails - * use, so it appears wherever a miner's balance already appears. accrued_sats - * is incremented by the carried amount and nothing else: a claim the coinbase - * paid is settled on chain and has no business in a ledger of what is owed. - * - * This is the honest form of the cost the mode has to admit to. It is not - * zero custody; it is custody proportional to dust, recorded per worker - * rather than implied. - * - * Returns the number of workers credited, or negative on error. */ -int store_record_window_carry(store_t *s, - const int64_t *worker_ids, - const int64_t *owed_sats, - const int64_t *paid_sats, - size_t n, 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 * share_hash semantics match store_record_share() above. diff --git a/src/stratum.c b/src/stratum.c index 052438e..b013fb7 100644 --- a/src/stratum.c +++ b/src/stratum.c @@ -138,7 +138,6 @@ struct stratum_job { * allocations rather than one per miner. */ coinbase_payee_t *payees; char *payee_addrs; - int64_t *payee_worker_ids; size_t n_payees; uint64_t created_ms; /* for retention ring */ @@ -243,7 +242,6 @@ void stratum_job_free(stratum_job_t *j) { } free(j->payees); free(j->payee_addrs); - free(j->payee_worker_ids); free(j); } @@ -251,33 +249,25 @@ void stratum_job_free(stratum_job_t *j) { * of payees and one arena the addresses live in, so a 200-miner window is not * 200 strdups that have to be unwound on every job retirement. */ int stratum_job_set_window(stratum_job_t *j, - const coinbase_payee_t *payees, - const int64_t *worker_ids, size_t n_payees) { + const coinbase_payee_t *payees, size_t n_payees) { if (!j) return -1; free(j->payees); j->payees = NULL; free(j->payee_addrs); j->payee_addrs = NULL; - free(j->payee_worker_ids); j->payee_worker_ids = NULL; j->n_payees = 0; if (!payees || n_payees == 0) return 0; enum { ADDR_STRIDE = 128 }; coinbase_payee_t *arr = calloc(n_payees, sizeof *arr); char *arena = calloc(n_payees, ADDR_STRIDE); - /* Worker ids ride alongside so a found block can name who carried. An - * address is not enough: two rigs can share one, and the ledger is per - * worker. */ - int64_t *ids = calloc(n_payees, sizeof *ids); - if (!arr || !arena || !ids) { free(arr); free(arena); free(ids); return -1; } + if (!arr || !arena) { free(arr); free(arena); return -1; } for (size_t i = 0; i < n_payees; ++i) { char *dst = arena + i * ADDR_STRIDE; snprintf(dst, ADDR_STRIDE, "%s", payees[i].address ? payees[i].address : ""); arr[i].address = dst; arr[i].sats = payees[i].sats; - ids[i] = worker_ids ? worker_ids[i] : 0; } j->payees = arr; j->payee_addrs = arena; - j->payee_worker_ids = ids; j->n_payees = n_payees; return 0; } @@ -787,14 +777,16 @@ static int conn_render_coinbase(stratum_server_t *s, stratum_conn_t *c, job->coinbasetxn_hex, job->payees, job->n_payees, s->cfg.operator_address, s->cfg.fee_bps, s->cfg.coinbase_tag, job->en1_size, job->en2_size, - s->cfg.max_coinbase_bytes, &parts, NULL, NULL, + s->cfg.max_coinbase_bytes, s->cfg.payout_floor_sats, + &parts, NULL, NULL, err, sizeof err); } else { rc = coinbase_build_window( job->height, job->value_sats, job->payees, job->n_payees, s->cfg.operator_address, s->cfg.fee_bps, job->wc_hex, s->cfg.coinbase_tag, job->en1_size, job->en2_size, - s->cfg.max_coinbase_bytes, &parts, NULL, err, sizeof err); + s->cfg.max_coinbase_bytes, s->cfg.payout_floor_sats, + &parts, NULL, err, sizeof err); } } else if (s->cfg.coinbase_pays_pool) { /* PPS-classic: every miner's coinbase is identical, paying the @@ -2044,15 +2036,20 @@ static int submit_with_job(stratum_server_t *s, stratum_conn_t *c, cJSON *id, c->vd_window_max_assigned = assigned_diff; } vardiff_maybe_retarget(s, c, now_ms(), buf, len); - /* Who this block's coinbase actually paid. Recomputed rather than - * remembered: the split is deterministic from the job and the config, so - * running the same builder again reproduces exactly what was rendered, - * and a found block is rare enough that the cost does not matter. Doing - * it here is the only option — the coinbase is decided when the template - * is built, but almost no template becomes a block, so nothing can be - * written to a ledger until one does. */ - if (is_block && block_accepted && s->cfg.on_window_outcome && - s->cfg.coinbase_pays_window && job->payees && job->n_payees > 0) { + /* What this block's coinbase actually paid, and what it forfeited. + * + * Recomputed rather than remembered: the split is deterministic from the + * job and the config, so running the same builder again reproduces + * exactly what was rendered, and a found block is rare enough that the + * cost does not matter. + * + * This is a log line rather than a ledger row on purpose. Forfeits are + * not a debt -- see coinbase_window_result_t -- but they ARE somebody's + * lost claim, and the one thing that makes a hard floor a rule instead of + * a trap is that it is visible. So every block that forfeits says so, in + * sats, at INFO. */ + if (is_block && block_accepted && s->cfg.coinbase_pays_window && + job->payees && job->n_payees > 0) { coinbase_parts_t throwaway; coinbase_window_result_t res; char werr[256] = {0}; @@ -2062,30 +2059,46 @@ static int submit_with_job(stratum_server_t *s, stratum_conn_t *c, cJSON *id, job->coinbasetxn_hex, job->payees, job->n_payees, s->cfg.operator_address, s->cfg.fee_bps, s->cfg.coinbase_tag, job->en1_size, job->en2_size, - s->cfg.max_coinbase_bytes, &throwaway, NULL, &res, - werr, sizeof werr); + s->cfg.max_coinbase_bytes, s->cfg.payout_floor_sats, + &throwaway, NULL, &res, werr, sizeof werr); } else { wrc = coinbase_build_window( job->height, job->value_sats, job->payees, job->n_payees, s->cfg.operator_address, s->cfg.fee_bps, job->wc_hex, s->cfg.coinbase_tag, job->en1_size, job->en2_size, - s->cfg.max_coinbase_bytes, &throwaway, &res, - werr, sizeof werr); + s->cfg.max_coinbase_bytes, s->cfg.payout_floor_sats, + &throwaway, &res, werr, sizeof werr); } if (wrc == 0) { coinbase_parts_free(&throwaway); - int64_t owed[COINBASE_MAX_PAYOUT_OUTPUTS]; - size_t n = job->n_payees > COINBASE_MAX_PAYOUT_OUTPUTS - ? COINBASE_MAX_PAYOUT_OUTPUTS : job->n_payees; - for (size_t i = 0; i < n; ++i) owed[i] = job->payees[i].sats; - s->cfg.on_window_outcome(s->cfg.ctx, block_hash_hex, - job->payee_worker_ids, owed, - res.paid_per_payee, n); + size_t dropped = res.dropped_below_floor + res.dropped_capped; + if (dropped > 0) { + LOG_INFO("pplns-coinbase: block %s paid %zu miner(s) %lld " + "sats directly; %zu claim(s) worth %lld sats were " + "forfeited to the operator (%zu below the %lld-sat " + "payout floor, %zu with no room in a %zu-byte " + "coinbase). Forfeited claims are NOT carried and are " + "not settled later.", + block_hash_hex, res.paid_count, + (long long)res.paid_sats, dropped, + (long long)res.forfeited_sats, + res.dropped_below_floor, + (long long)s->cfg.payout_floor_sats, + res.dropped_capped, + s->cfg.max_coinbase_bytes + ? s->cfg.max_coinbase_bytes + : (size_t)COINBASE_DEFAULT_MAX_BYTES); + } else { + LOG_INFO("pplns-coinbase: block %s paid all %zu miner(s) in " + "the window %lld sats directly", + block_hash_hex, res.paid_count, + (long long)res.paid_sats); + } } else { - /* The ledger is the only record of who is owed, so failing to - * write it is worth saying loudly even though the block stands. */ - LOG_WARN("stratum: could not determine the window outcome for " - "block %s: %s — carried claims are unrecorded", + /* The block stands either way -- it was already accepted -- but + * an operator who cannot see what a block paid cannot answer a + * miner asking why it was not paid. */ + LOG_WARN("stratum: could not determine what block %s paid: %s", block_hash_hex, werr); } } diff --git a/src/stratum.h b/src/stratum.h index 6ccd9a8..fbf0c92 100644 --- a/src/stratum.h +++ b/src/stratum.h @@ -78,8 +78,7 @@ stratum_job_t *stratum_job_new( * * Returns 0 on success, negative on allocation failure. */ int stratum_job_set_window(stratum_job_t *j, - const coinbase_payee_t *payees, - const int64_t *worker_ids, size_t n_payees); + const coinbase_payee_t *payees, size_t n_payees); void stratum_job_free(stratum_job_t *j); @@ -107,23 +106,6 @@ typedef int (*block_submit_fn)(void *ctx, const char *block_hex, * `submit_error` the reason when it was not. A candidate the node refused is * still reported here — it is recorded as 'rejected' rather than dropped, * because a silent reject is how phantom rewards went unnoticed. */ -/* Who the coinbase of a found block actually paid, and who it did not. - * - * Called once per found block in pplns-coinbase mode, before on_block_found. - * `paid_sats[i]` is what worker_ids[i] received in that coinbase; 0 means the - * claim carried, because it was below the floor or the byte budget had no - * room. `owed_sats[i]` is what it was entitled to either way. - * - * This is the only moment the information exists: the coinbase is decided - * when the template is built, but almost every template never becomes a - * block, so nothing can be written to a ledger until one does. */ -typedef void (*window_outcome_fn)(void *ctx, - const char *block_hash, - const int64_t *worker_ids, - const int64_t *owed_sats, - const int64_t *paid_sats, - size_t n); - typedef void (*block_found_fn)(void *ctx, const char *worker_name, const char *finder_address, @@ -223,6 +205,7 @@ typedef struct { * is a snapshot taken when the template was built. */ int coinbase_pays_window; size_t max_coinbase_bytes; /* 0 = COINBASE_DEFAULT_MAX_BYTES */ + int64_t payout_floor_sats; /* below this a claim is forfeited, not paid */ /* 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 @@ -290,7 +273,6 @@ typedef struct { reject_observer_fn on_reject; block_submit_fn on_block; block_found_fn on_block_found; - window_outcome_fn on_window_outcome; /* pplns-coinbase only */ } stratum_cfg_t; typedef struct stratum_server stratum_server_t; diff --git a/tests/test_coinbase.c b/tests/test_coinbase.c index 43844e1..5606edf 100644 --- a/tests/test_coinbase.c +++ b/tests/test_coinbase.c @@ -837,11 +837,11 @@ static void test_window_pays_each_miner_its_own_output(void) { }; int rc = coinbase_build_window(800000, 5000000000LL, payees, 3, WOP, 100, NULL, "/simplepool/", 4, 8, - 0, &parts, &res, err, sizeof err); + 0, 0, &parts, &res, err, sizeof err); assert(rc == 0); assert(res.paid_count == 3); assert(res.fee_sats == 50000000LL); - assert(res.carry_sats == 0); + assert(res.forfeited_sats == 0); assert(res.paid_sats == 4950000000LL); uint64_t n = 0; int64_t sum = 0; @@ -859,22 +859,24 @@ static void test_a_split_that_does_not_add_up_is_refused(void) { const coinbase_payee_t short_[] = { { WA, 1000000LL } }; int rc = coinbase_build_window(800000, 5000000000LL, short_, 1, WOP, 100, NULL, NULL, 4, 8, - 0, &parts, NULL, err, sizeof err); + 0, 0, &parts, NULL, err, sizeof err); assert(rc < 0); assert(strstr(err, "payees sum to") != NULL); const coinbase_payee_t over[] = { { WA, 9000000000LL } }; rc = coinbase_build_window(800000, 5000000000LL, over, 1, WOP, 100, NULL, NULL, 4, 8, - 0, &parts, NULL, err, sizeof err); + 0, 0, &parts, NULL, err, sizeof err); assert(rc < 0); printf("ok: a window split that does not sum to the block is refused\n"); } -/* Dust. A miner too small to pay cannot simply be dropped -- its value has to - * go somewhere, and the only honest somewhere is the operator, who then owes - * it. This is the custodial balance the design has to admit to. */ -static void test_a_dust_payee_is_carried_not_burnt(void) { +/* Below the floor. The value has to go somewhere -- a coinbase paying out + * less than it may forfeits the difference to nobody -- and the somewhere is + * the operator output. It is income, not a debt: nothing records it and + * nothing settles it later. This is the design's harshest edge, so it is + * pinned rather than left implied. */ +static void test_a_payee_below_the_floor_is_forfeited_to_the_operator(void) { coinbase_parts_t parts; char err[256]; coinbase_window_result_t res; /* fee 1% of 100,000,000 = 1,000,000; payable 99,000,000. */ @@ -884,26 +886,68 @@ static void test_a_dust_payee_is_carried_not_burnt(void) { }; int rc = coinbase_build_window(800000, 100000000LL, payees, 2, WOP, 100, NULL, NULL, 4, 8, - 0, &parts, &res, err, sizeof err); + 0, 0, &parts, &res, err, sizeof err); assert(rc == 0); assert(res.paid_count == 1); - assert(res.dropped_dust == 1); - assert(res.carry_sats == 100LL); - /* The fee is reported separately from what is merely being held, so a - * ledger can tell the operator's income from its liability. */ + assert(res.dropped_below_floor == 1); + assert(res.forfeited_sats == 100LL); + /* Forfeits are reported apart from the fee. The operator output carries + * both, but an operator publishing its take has to be able to say which + * part was the advertised fee and which part was somebody's lost claim. */ assert(res.fee_sats == 1000000LL); uint64_t n = 0; int64_t sum = 0; window_outputs(&parts, 12, &n, &sum); assert(n == 2); /* one miner + the operator */ assert(sum == 100000000LL); /* still the whole block */ + /* And the operator output is fee + forfeit, not just the fee. */ + assert(sum - res.paid_sats == res.fee_sats + res.forfeited_sats); coinbase_parts_free(&parts); - printf("ok: a dust payee is carried on the operator output, not burnt\n"); + printf("ok: a payee below the floor is forfeited to the operator\n"); } -/* The output cap is about marketplaces rejecting an oversized coinbase, so it - * has to fall on the smallest claims: they are the ones for whom waiting a - * block costs least, and whose carried balance is smallest. */ +/* The floor is configurable, and raising it forfeits claims that the dust + * limit alone would have paid. That is the knob an operator uses to trade + * coinbase bytes against how small a miner it is willing to serve. */ +static void test_the_payout_floor_is_configurable(void) { + coinbase_parts_t parts; char err[256]; + coinbase_window_result_t res; + /* 10,000 sats: comfortably relayable, so only an explicit floor drops it. */ + const coinbase_payee_t payees[] = { { WA, 99990000LL }, { WB, 10000LL } }; + + /* Default floor (dust): both are paid. */ + assert(coinbase_build_window(800000, 100000000LL, payees, 2, + WOP, 0, NULL, NULL, 4, 8, + 0, 0, &parts, &res, err, sizeof err) == 0); + assert(res.paid_count == 2); + assert(res.forfeited_sats == 0); + coinbase_parts_free(&parts); + + /* Floor above the small claim: it is forfeited, not carried. */ + assert(coinbase_build_window(800000, 100000000LL, payees, 2, + WOP, 0, NULL, NULL, 4, 8, + 0, 50000, &parts, &res, err, sizeof err) == 0); + assert(res.paid_count == 1); + assert(res.dropped_below_floor == 1); + assert(res.forfeited_sats == 10000LL); + coinbase_parts_free(&parts); + + /* A floor below the dust limit is clamped up to it rather than honoured: + * an output under 546 sats is not relayable, so there is no lower floor + * to have and pretending otherwise would build an unspendable block. */ + const coinbase_payee_t dusty[] = { { WA, 99999900LL }, { WB, 100LL } }; + assert(coinbase_build_window(800000, 100000000LL, dusty, 2, + WOP, 0, NULL, NULL, 4, 8, + 0, 1, &parts, &res, err, sizeof err) == 0); + assert(res.paid_count == 1); + assert(res.dropped_below_floor == 1); + coinbase_parts_free(&parts); + printf("ok: the payout floor is configurable and clamped up to dust\n"); +} + +/* The byte budget is about marketplaces rejecting an oversized coinbase, so + * it has to fall on the smallest claims: paying largest-first means the + * forfeit lands on whoever has least at stake in it. */ static void test_the_cap_falls_on_the_smallest_claims(void) { coinbase_parts_t parts; char err[256]; coinbase_window_result_t res; @@ -912,8 +956,8 @@ static void test_the_cap_falls_on_the_smallest_claims(void) { }; /* fee 1% of 10,101,010 ~ 101,010; make the numbers exact instead. */ int64_t value = 1000000LL + 3000000LL + 6000000LL; /* fee_bps 0: no fee */ - /* The operator address is still required: capping produces carry, and - * carry needs somewhere to ride even when there is no fee. */ + /* The operator address is still required: capping produces a forfeit, + * and a forfeit needs somewhere to go even when there is no fee. */ int rc = coinbase_build_window(800000, value, payees, 3, WOP, 0, NULL, NULL, 4, 8, /* Byte budget admitting exactly two of the @@ -922,43 +966,45 @@ static void test_the_cap_falls_on_the_smallest_claims(void) { * 112 bytes, and each P2WPKH payout costs * 31, so 174 fits two and 205 would fit * three. */ - 180, + 180, 0, &parts, &res, err, sizeof err); assert(rc == 0); assert(res.paid_count == 2); assert(res.dropped_capped == 1); - /* The 1,000,000 claim is the one that waits, not the 6,000,000 one. */ - assert(res.carry_sats == 1000000LL); + /* The 1,000,000 claim is the one that loses out, not the 6,000,000 one. */ + assert(res.forfeited_sats == 1000000LL); assert(res.paid_sats == 9000000LL); coinbase_parts_free(&parts); printf("ok: the output cap drops the smallest claims first\n"); } -/* With no operator address there is nowhere to carry to, so a window that - * cannot be paid in full has to be refused rather than silently burn it. */ -static void test_carry_without_an_operator_address_is_refused(void) { +/* With no operator address there is nowhere for a forfeit to go, so a window + * that cannot be paid in full has to be refused rather than silently burn the + * difference into the void. */ +static void test_a_forfeit_without_an_operator_address_is_refused(void) { coinbase_parts_t parts; char err[256]; const coinbase_payee_t payees[] = { { WA, 999900LL }, { WB, 100LL }, }; int rc = coinbase_build_window(800000, 1000000LL, payees, 2, NULL, 0, NULL, NULL, 4, 8, - 0, &parts, NULL, err, sizeof err); + 0, 0, &parts, NULL, err, sizeof err); assert(rc < 0); - assert(strstr(err, "no operator_address to carry") != NULL); - printf("ok: carry with nowhere to go is refused, not burnt\n"); + assert(strstr(err, "no operator_address to receive") != NULL); + printf("ok: a forfeit with nowhere to go is refused, not burnt\n"); } -/* If nobody clears dust, paying the operator the whole block and calling it a - * fee would be the worst possible outcome. */ +/* If nobody clears the floor, paying the operator the whole block and calling + * it a fee would be the worst possible outcome -- forfeits are meant to be the + * edge of the distribution, never the whole of it. Refuse the block instead. */ static void test_a_window_of_only_dust_is_refused(void) { coinbase_parts_t parts; char err[256]; const coinbase_payee_t payees[] = { { WA, 100LL }, { WB, 100LL } }; int rc = coinbase_build_window(800000, 200LL, payees, 2, WOP, 0, NULL, NULL, 4, 8, - 0, &parts, NULL, err, sizeof err); + 0, 0, &parts, NULL, err, sizeof err); assert(rc < 0); - assert(strstr(err, "dust limit") != NULL); + assert(strstr(err, "payout floor") != NULL); printf("ok: a window of nothing but dust is refused\n"); } @@ -966,7 +1012,7 @@ static void test_an_empty_window_is_refused(void) { coinbase_parts_t parts; char err[256]; int rc = coinbase_build_window(800000, 5000000000LL, NULL, 0, WOP, 100, NULL, NULL, 4, 8, - 0, &parts, NULL, err, sizeof err); + 0, 0, &parts, NULL, err, sizeof err); assert(rc < 0); assert(strstr(err, "nobody to pay") != NULL); printf("ok: an empty window is refused\n"); @@ -982,7 +1028,7 @@ static void test_the_witness_commitment_is_preserved(void) { const coinbase_payee_t payees[] = { { WA, 5000000000LL } }; int rc = coinbase_build_window(800000, 5000000000LL, payees, 1, NULL, 0, wc, NULL, 4, 8, - 0, &parts, &res, err, sizeof err); + 0, 0, &parts, &res, err, sizeof err); assert(rc == 0); uint64_t n = 0; int64_t sum = 0; window_outputs(&parts, 12, &n, &sum); @@ -1001,9 +1047,9 @@ static void test_the_coinbase_is_deterministic(void) { }; int64_t value = 5000000LL; assert(coinbase_build_window(800000, value, payees, 3, NULL, 0, NULL, - "/sp/", 4, 8, 0, &a, NULL, err, sizeof err) == 0); + "/sp/", 4, 8, 0, 0, &a, NULL, err, sizeof err) == 0); assert(coinbase_build_window(800000, value, payees, 3, NULL, 0, NULL, - "/sp/", 4, 8, 0, &b, NULL, err, sizeof err) == 0); + "/sp/", 4, 8, 0, 0, &b, NULL, err, sizeof err) == 0); assert(a.cb2_len == b.cb2_len); assert(memcmp(a.cb2, b.cb2, a.cb2_len) == 0); coinbase_parts_free(&a); @@ -1071,12 +1117,12 @@ static void test_window_from_template_preserves_commitments(void) { int64_t a = (reward * 6) / 10; const coinbase_payee_t payees[] = { { WA, a }, { WB, reward - a } }; int rc = coinbase_build_window_from_template( - ENF_COINBASE_HEX, payees, 2, NULL, 0, "/x/", 4, 4, 0, + ENF_COINBASE_HEX, payees, 2, NULL, 0, "/x/", 4, 4, 0, 0, &parts, &has_witness, &res, err, sizeof err); if (rc != 0) fprintf(stderr, "window_from_template err: %s\n", err); assert(rc == 0); assert(res.paid_count == 2); - assert(res.carry_sats == 0); + assert(res.forfeited_sats == 0); assert(res.paid_sats == reward); /* The enforcer's own outputs must survive: one spendable output was @@ -1096,6 +1142,49 @@ static void test_window_from_template_preserves_commitments(void) { printf("ok: window from template pays N miners and keeps the commitments\n"); } +/* coinbase_template_reward() has one job: hand a caller the number the + * builders will insist the payees sum to. So it is asserted against the + * builder, not against a constant — a constant would still be "right" on the + * day the two stopped agreeing, which is the only day it matters. + * + * If they ever diverge, main.c divides one number and the builder checks + * against another, so every job is refused on every connection and the pool + * stops publishing work with nothing but a repeated warning. */ +static void test_the_template_reward_matches_what_the_builder_splits(void) { + char err[256] = {0}; + coinbase_parts_t probe; int64_t builder_reward = 0, unused = 0; + assert(coinbase_build_from_template(ENF_COINBASE_HEX, ENF_ADDR, NULL, 0, + NULL, 4, 4, &probe, NULL, + &builder_reward, &unused, + err, sizeof err) == 0); + coinbase_parts_free(&probe); + assert(builder_reward > 0); + + int64_t reward = 0; + assert(coinbase_template_reward(ENF_COINBASE_HEX, &reward) == 0); + assert(reward == builder_reward); + + /* And the number is usable: a window split against it is accepted, which + * is the whole point of asking. */ + coinbase_parts_t parts; + coinbase_window_result_t res; + const coinbase_payee_t payees[] = { { WA, reward / 2 }, + { WB, reward - reward / 2 } }; + assert(coinbase_build_window_from_template(ENF_COINBASE_HEX, payees, 2, + NULL, 0, NULL, 4, 4, 0, 0, + &parts, NULL, &res, + err, sizeof err) == 0); + assert(res.paid_sats == reward); + coinbase_parts_free(&parts); + + /* Garbage in, refusal out — never a plausible-looking zero, which would + * make main.c divide nothing across the window and pay everyone dust. */ + assert(coinbase_template_reward("not hex", &reward) < 0); + assert(coinbase_template_reward(NULL, &reward) < 0); + assert(coinbase_template_reward(ENF_COINBASE_HEX, NULL) < 0); + printf("ok: the template reward is exactly what the builder splits\n"); +} + /* The two builders must divide a window identically. They share a resolver * precisely so that a drivechain pool and a plain-bitcoind pool cannot pay * the same miners different amounts. */ @@ -1110,7 +1199,7 @@ static void test_both_window_builders_split_identically(void) { &unused, err, sizeof err) == 0); coinbase_parts_free(&probe); - /* Three claims, one of them dust, so dust and carry are exercised too. + /* Three claims, one below the floor, so forfeiting is exercised too. * They must sum to the payable amount, i.e. net of the 1% fee. */ int64_t fee = (reward * 100) / 10000; int64_t payable = reward - fee; @@ -1118,17 +1207,17 @@ static void test_both_window_builders_split_identically(void) { { WA, payable - 40000 - 100 }, { WB, 40000 }, { WC, 100 }, }; assert(coinbase_build_window(800000, reward, payees, 3, WOP, 100, NULL, - "/x/", 4, 4, 0, &p1, &r1, err, sizeof err) == 0); + "/x/", 4, 4, 0, 0, &p1, &r1, err, sizeof err) == 0); assert(coinbase_build_window_from_template(ENF_COINBASE_HEX, payees, 3, - WOP, 100, "/x/", 4, 4, 0, + WOP, 100, "/x/", 4, 4, 0, 0, &p2, NULL, &r2, err, sizeof err) == 0); assert(r1.paid_count == r2.paid_count); assert(r1.paid_sats == r2.paid_sats); assert(r1.fee_sats == r2.fee_sats); - assert(r1.carry_sats == r2.carry_sats); - assert(r1.dropped_dust == r2.dropped_dust); - assert(r1.dropped_dust == 1); - assert(r1.carry_sats >= 100); + assert(r1.forfeited_sats == r2.forfeited_sats); + assert(r1.dropped_below_floor == r2.dropped_below_floor); + assert(r1.dropped_below_floor == 1); + assert(r1.forfeited_sats >= 100); coinbase_parts_free(&p1); coinbase_parts_free(&p2); printf("ok: both window builders split a window identically\n"); @@ -1173,20 +1262,21 @@ static void test_commitments_eat_the_payout_budget(void) { * enforcer template admits 5 and a bare coinbase admits 6. */ const size_t BUDGET = 300; assert(coinbase_build_window_from_template(ENF_COINBASE_HEX, payees, N, - WOP, 0, NULL, 4, 4, BUDGET, + WOP, 0, NULL, 4, 4, BUDGET, 0, &parts, NULL, &res, err, sizeof err) == 0); size_t paid_with_template = res.paid_count; assert(paid_with_template > 0 && paid_with_template < N); - /* Nothing is lost: whatever did not fit is carried, not dropped. */ + /* The block is still fully spent: whatever did not fit was forfeited to + * the operator rather than left unpaid in the coinbase. */ assert(res.dropped_capped == N - paid_with_template); - assert(res.paid_sats + res.carry_sats + res.fee_sats == reward); + assert(res.paid_sats + res.forfeited_sats + res.fee_sats == reward); coinbase_parts_free(&parts); /* The same window and the same budget, built from scratch — no template, * so no commitment OP_RETURNs spending the budget. More miners fit. */ assert(coinbase_build_window(800000, reward, payees, N, WOP, 0, NULL, - NULL, 4, 4, BUDGET, &parts, &res, + NULL, 4, 4, BUDGET, 0, &parts, &res, err, sizeof err) == 0); assert(res.paid_count > paid_with_template); coinbase_parts_free(&parts); @@ -1213,7 +1303,7 @@ static void test_the_built_coinbase_respects_its_budget(void) { for (size_t budget = 200; budget <= 600; budget += 100) { assert(coinbase_build_window(800000, total, payees, N, WOP, 0, NULL, - "/sp/", 4, 8, budget, &parts, &res, + "/sp/", 4, 8, budget, 0, &parts, &res, err, sizeof err) == 0); /* cb1 + extranonce + cb2 is the whole serialized coinbase. */ size_t built = parts.cb1_len + 12 + parts.cb2_len; @@ -1230,13 +1320,15 @@ int main(void) { test_p2pkh_address(); test_the_built_coinbase_respects_its_budget(); test_commitments_eat_the_payout_budget(); + test_the_template_reward_matches_what_the_builder_splits(); test_both_window_builders_split_identically(); test_window_from_template_preserves_commitments(); test_window_pays_each_miner_its_own_output(); test_a_split_that_does_not_add_up_is_refused(); - test_a_dust_payee_is_carried_not_burnt(); + test_a_payee_below_the_floor_is_forfeited_to_the_operator(); + test_the_payout_floor_is_configurable(); test_the_cap_falls_on_the_smallest_claims(); - test_carry_without_an_operator_address_is_refused(); + test_a_forfeit_without_an_operator_address_is_refused(); test_a_window_of_only_dust_is_refused(); test_an_empty_window_is_refused(); test_the_witness_commitment_is_preserved(); diff --git a/tests/test_config.c b/tests/test_config.c index 4ae5c6f..f5ad5cc 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -271,6 +271,39 @@ static void test_the_coinbase_budget_defaults_and_parses(void) { CHECK(cfg.coinbase_max_bytes == 820); } +/* The payout floor decides who this pool refuses to serve, so it has to parse + * exactly and default to something an operator can defend. It is the harshest + * knob in the file: above it a miner is paid out of the block, below it a + * miner mines here and earns nothing. */ +static void test_the_payout_floor_defaults_and_parses(void) { + proxy_config_t cfg; char err[256] = {0}; + char body[512]; + snprintf(body, sizeof body, + "operator_address = %s\npool_mode = pplns-coinbase\n", VALID_ADDR); + CHECK(load_text(body, &cfg, err, sizeof err) == 0); + CHECK(cfg.pplns_payout_floor_sats == 546); /* the dust limit */ + + snprintf(body, sizeof body, + "operator_address = %s\npool_mode = pplns-coinbase\n" + "pplns_payout_floor_sats = 25000\n", VALID_ADDR); + CHECK(load_text(body, &cfg, err, sizeof err) == 0); + CHECK(cfg.pplns_payout_floor_sats == 25000); + + /* Zero is legitimate: it means "pay anything the dust limit allows", and + * coinbase.c clamps it up. Only a negative is a typo. */ + snprintf(body, sizeof body, + "operator_address = %s\npool_mode = pplns-coinbase\n" + "pplns_payout_floor_sats = 0\n", VALID_ADDR); + CHECK(load_text(body, &cfg, err, sizeof err) == 0); + CHECK(cfg.pplns_payout_floor_sats == 0); + + snprintf(body, sizeof body, + "operator_address = %s\npool_mode = pplns-coinbase\n" + "pplns_payout_floor_sats = -1\n", VALID_ADDR); + CHECK(load_text(body, &cfg, err, sizeof err) != 0); + CHECK(strstr(err, "pplns_payout_floor_sats") != NULL); +} + /* ---- listener lines ------------------------------------------------------ * * A `listener` line is how rented hashrate is served its own difficulty. A @@ -362,6 +395,7 @@ int main(void) { test_inline_comment_still_strips(); test_rejects_bad_operator_address(); test_the_coinbase_budget_defaults_and_parses(); + test_the_payout_floor_defaults_and_parses(); test_a_tiny_coinbase_budget_is_refused(); test_pplns_coinbase_validates_the_window(); test_pplns_coinbase_refuses_a_pool_wallet(); diff --git a/tests/test_pplns_coinbase_regtest.sh b/tests/test_pplns_coinbase_regtest.sh index 74ca2a4..6b44796 100755 --- a/tests/test_pplns_coinbase_regtest.sh +++ b/tests/test_pplns_coinbase_regtest.sh @@ -23,23 +23,26 @@ # the chain rather than out of anything simplepool wrote. # 3. NO output pays an address the pool controls. That is the whole claim of # the mode and it is the one thing a bookkeeping bug cannot fake. -# 4. only UNPAID claims are owed off-chain. A miner the coinbase paid must -# not appear in pps_credits at all: a row there would mean the pool -# believes it owes money it already paid on chain. +# 4. NOTHING is owed off-chain, ever. pps_credits must be empty: this mode +# writes no ledger row at all, so a row of any size means some other +# rail's code path ran. +# 5. the policy is stated in the log. A claim below the payout floor is +# forfeited to the operator and never settled, which is a trap unless +# the operator can see it — so the disclosure lines are asserted here +# exactly like the money is. # -# NOT covered here, deliberately: the carry ledger with something actually in -# it. Carry needs a window where some claims fit the coinbase and others do -# not, which needs several miners of very different sizes — and this harness -# drives one cpuminer. Shrinking the byte budget instead does not produce it -# either: when NOTHING fits, the builder refuses, no coinbase is rendered and -# no block is found, so there is nothing to record. +# NOT covered here, deliberately: a forfeit with something actually in it. +# That needs a window holding claims of very different sizes, and this harness +# drives cpuminers against one address. Squeezing the byte budget instead does +# not produce it either: with a single payee, either it fits or the builder +# refuses, no coinbase is rendered and no block is found. # # An earlier version of this file had a stage that squeezed the budget and # printed how much had carried. It printed 0 every time and passed regardless, -# which is worse than no stage at all. The carry ledger is covered by -# tests/test_store.c instead, where the outcome can be stated exactly and is -# mutation-verified; what is missing is an end-to-end run with a mixed-size -# window, and it is missing on purpose rather than by oversight. +# which is worse than no stage at all. The forfeit arithmetic is covered in +# tests/test_coinbase.c instead, where the amounts can be stated exactly and +# are mutation-verified. What is missing is an end-to-end run with a +# mixed-size window, and it is missing on purpose rather than by oversight. # # Env: # REGTEST_DIR data dir, WIPED each run (default: /.regtest-cbwin) @@ -311,21 +314,56 @@ if unknown: print(f" miner {paid[miner]} sats, operator {paid.get(op, 0)} sats") PY -stage "assert only UNPAID claims are owed off-chain" -# The payment was the block, so a miner the coinbase paid must not appear in -# pps_credits at all: a row there would mean the pool believes it owes money -# it has already paid on chain. +stage "assert NOTHING is owed off-chain" +# The payment was the block, so there is no ledger at all in this mode: not +# for the miners the coinbase paid, and not for the ones it could not. A claim +# below the payout floor is forfeited to the operator outright — it is income, +# not a debt, and nothing records it. # -# The ledger is not empty by definition, though. A claim below the payout -# floor, or one the byte budget had no room for, rides on the operator output -# — the operator is holding it, and owes it. Here the single miner takes the -# whole block and clears the floor easily, so nothing should carry. +# So this is unconditional, which is what makes it worth asserting. Any row +# here means some other rail's crediting path ran against a pplns-coinbase +# pool, which is the bug that would quietly recreate the custody this mode +# exists to remove. CREDITS="$(sqlite3 "$POOL_DB" "SELECT COALESCE(SUM(accrued_sats),0) FROM pps_credits")" ROWS="$(sqlite3 "$POOL_DB" "SELECT COUNT(*) FROM pps_credits")" echo " pps_credits rows=$ROWS accrued=$CREDITS" [ "$ROWS" = "0" ] && [ "$CREDITS" = "0" ] || { - echo "FAIL: pplns-coinbase recorded $CREDITS sats owed across $ROWS row(s)," >&2 - echo " but the coinbase paid this miner in full" >&2 + echo "FAIL: pplns-coinbase wrote $CREDITS sats across $ROWS ledger row(s)." >&2 + echo " This mode has no ledger: the block IS the payment." >&2 + exit 1; } + +stage "assert the payout floor is disclosed, not silent" +# The floor decides who this pool refuses to pay, and a miner below it earns +# nothing however long it mines. That is a defensible policy and an +# indefensible surprise, so the operator has to be told twice: once at +# startup, and once per block with the actual numbers. If these lines ever +# regress the policy silently becomes a trap, which is why they are asserted +# here alongside the money. +grep -q "payout floor 546 sats" "$POOL_LOG" || { + echo "FAIL: the pool never stated its payout floor at startup" >&2 + grep -i "floor" "$POOL_LOG" | head -5 >&2 + exit 1; } +grep -q "NOT PAID" "$POOL_LOG" || { + echo "FAIL: the startup line does not say a miner below the floor is unpaid" >&2 + exit 1; } +echo " startup: $(grep -o 'payout floor [0-9]* sats' "$POOL_LOG" | head -1)" + +# And per block: this harness has one miner, who takes the whole window, so +# the expected line is the all-paid one. Asserting the all-paid wording rather +# than merely "some line was printed" is what keeps this from passing on a +# build where the reporting broke in the direction of saying nothing. +grep -q "paid all .* miner(s) in the window" "$POOL_LOG" || { + echo "FAIL: no per-block payment line for a block that paid everyone" >&2 + grep -i "pplns-coinbase: block" "$POOL_LOG" | tail -5 >&2 + exit 1; } +echo " per block: $(grep -o 'paid all [0-9]* miner(s) in the window [0-9]* sats' "$POOL_LOG" | tail -1)" + +# The window-level warning is the one an operator can act on BEFORE a block +# makes it real. With everyone clearing the floor it must say so rather than +# say nothing — a line that only ever appears on the bad path is a line +# nobody notices is missing. +grep -q "every miner in the window clears the 546-sat payout floor" "$POOL_LOG" || { + echo "FAIL: the pool never reported the window against its floor" >&2 exit 1; } stage "assert the block was recorded, and needs no distribution" diff --git a/tests/test_store.c b/tests/test_store.c index 6d0ee0b..02cc4e8 100644 --- a/tests/test_store.c +++ b/tests/test_store.c @@ -58,16 +58,6 @@ static int64_t scalar_i64(sqlite3 *db, const char *sql) { return v; } -/* Same as scalar_i64 but opens the file itself, for assertions made after - * store_close(). */ -static int64_t scalar_path(const char *path, const char *sql) { - sqlite3 *db = NULL; - assert(sqlite3_open(path, &db) == SQLITE_OK); - int64_t v = scalar_i64(db, sql); - sqlite3_close(db); - return v; -} - /* Copies into `out` because the sqlite3_stmt is finalized before returning. * Writes "" for SQL NULL, and returns whether the column was non-NULL — the * identity test needs to tell "stored blank" from "stored nothing". */ @@ -1459,124 +1449,6 @@ static void test_an_empty_window_returns_nothing_not_an_error(void) { printf(" ok test_an_empty_window_returns_nothing_not_an_error\n"); } -/* ---- the coinbase-direct carry ledger ---------------------------------- - * - * pplns-coinbase pays miners in the block itself, so there is almost no - * ledger — except for the claims the coinbase could NOT carry: below the - * payout floor, or past the byte budget. Those ride on the operator output, - * which means the operator holds them and owes them. - * - * Recording that is what makes "a small custodial balance" an honest - * statement rather than a hidden one. */ -static void test_only_unpaid_claims_are_recorded_as_owed(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); - - /* Three workers exist. */ - for (int i = 0; i < 3; ++i) { - char name[16], addr[16]; - snprintf(name, sizeof name, "w%d", i + 1); - snprintf(addr, sizeof addr, "addr_%d", i + 1); - assert(store_record_share_addr(s, name, addr, 1000ULL + (uint64_t)i, - 1.0, 0, NULL, 0, 0.0) == 0); - } - assert(store_flush(s) == 0); - - /* w1 paid in full, w2 paid nothing, w3 paid nothing. */ - const int64_t ids[] = { 1, 2, 3 }; - const int64_t owed[] = { 500000, 400, 900 }; - const int64_t paid[] = { 500000, 0, 0 }; - char err[256] = {0}; - assert(store_record_window_carry(s, ids, owed, paid, 3, err, sizeof err) == 2); - store_close(s); - - /* The miner the coinbase paid is owed nothing and must not appear: a row - * of zero would put a settled miner into a ledger of debts. */ - assert(scalar_path(path, "SELECT COUNT(*) FROM pps_credits") == 2); - assert(scalar_path(path, "SELECT COUNT(*) FROM pps_credits WHERE worker_id = 1") == 0); - assert(scalar_path(path, "SELECT accrued_sats FROM pps_credits WHERE worker_id = 2") == 400); - assert(scalar_path(path, "SELECT accrued_sats FROM pps_credits WHERE worker_id = 3") == 900); - printf(" ok test_only_unpaid_claims_are_recorded_as_owed\n"); -} - -/* Carry accumulates across blocks. That is the whole point: a miner too small - * to pay in one block becomes payable once enough blocks have passed. */ -static void test_carry_accumulates_across_blocks(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); - assert(store_record_share_addr(s, "w1", "addr_1", 1000, 1.0, - 0, NULL, 0, 0.0) == 0); - assert(store_flush(s) == 0); - - const int64_t ids[] = { 1 }; - const int64_t paid[] = { 0 }; - char err[256] = {0}; - for (int i = 0; i < 5; ++i) { - const int64_t owed[] = { 120 }; - assert(store_record_window_carry(s, ids, owed, paid, 1, - err, sizeof err) == 1); - } - store_close(s); - assert(scalar_path(path, "SELECT accrued_sats FROM pps_credits WHERE worker_id = 1") == 600); - printf(" ok test_carry_accumulates_across_blocks\n"); -} - -/* A partially-paid claim carries only the remainder, not the whole of it. - * Recording the full claim would have the pool owing money it already paid. */ -static void test_a_partly_paid_claim_carries_only_the_remainder(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); - assert(store_record_share_addr(s, "w1", "addr_1", 1000, 1.0, - 0, NULL, 0, 0.0) == 0); - assert(store_flush(s) == 0); - - const int64_t ids[] = { 1 }; - const int64_t owed[] = { 1000 }; - const int64_t paid[] = { 600 }; - char err[256] = {0}; - assert(store_record_window_carry(s, ids, owed, paid, 1, err, sizeof err) == 1); - store_close(s); - assert(scalar_path(path, "SELECT accrued_sats FROM pps_credits WHERE worker_id = 1") == 400); - printf(" ok test_a_partly_paid_claim_carries_only_the_remainder\n"); -} - -/* Defensive: an unknown worker id, and an empty window, must not write - * anything or fail. */ -static void test_the_carry_ledger_ignores_nothing_to_record(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); - char err[256] = {0}; - const int64_t ids[] = { 0 }; /* no such worker */ - const int64_t owed[] = { 900 }; - const int64_t paid[] = { 0 }; - assert(store_record_window_carry(s, ids, owed, paid, 1, err, sizeof err) == 0); - assert(store_record_window_carry(s, ids, owed, paid, 0, err, sizeof err) == 0); - assert(store_record_window_carry(s, NULL, owed, paid, 1, err, sizeof err) < 0); - store_close(s); - assert(scalar_path(path, "SELECT COUNT(*) FROM pps_credits") == 0); - printf(" ok test_the_carry_ledger_ignores_nothing_to_record\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(); @@ -1631,10 +1503,6 @@ int main(void) { test_pplns_distributes_the_window(); test_pplns_takes_the_operator_fee(); test_pplns_distributes_two_blocks_in_one_pass(); - test_the_carry_ledger_ignores_nothing_to_record(); - test_a_partly_paid_claim_carries_only_the_remainder(); - test_carry_accumulates_across_blocks(); - test_only_unpaid_claims_are_recorded_as_owed(); test_an_empty_window_returns_nothing_not_an_error(); test_a_window_wider_than_the_cap_says_so(); test_a_worker_with_no_address_is_left_out_of_the_split(); diff --git a/tests/test_stratum.c b/tests/test_stratum.c index e5306b6..a879c3e 100644 --- a/tests/test_stratum.c +++ b/tests/test_stratum.c @@ -2759,7 +2759,7 @@ static void test_pplns_coinbase_pays_every_miner_in_the_window(void) { { TEST_ADDR, 3000000000LL }, { TEST_ADDR2, 2000000000LL }, }; - CHECK(stratum_job_set_window(job, win, NULL, 2) == 0); + CHECK(stratum_job_set_window(job, win, 2) == 0); stratum_server_set_job(s, job, 1); stratum_conn_t *c = stratum_conn_new_for_test(s); From 97946603c3c627cf5837e5e5a86b7c2b09353d14 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 8 Sep 2026 17:26:44 +0200 Subject: [PATCH 10/36] tests: prove solo mode on a real chain, with two miners Solo is the default mode and the one most operators run, and it had no end-to-end test anywhere. tests/test_integration.sh looks like one and is not: it subscribes, authorizes, submits one deliberately bogus share and asserts a reject row. It never mines, so it cannot see the thing solo IS -- a coinbase paying the finder -- and it is not in CI either. The mode with the fewest moving parts had the weakest evidence. test_solo_regtest.sh mines two blocks through stratum with two different miner addresses and asserts, from the chain: - each block's coinbase pays ITS OWN finder, plus the operator fee, and nothing else. Two addresses is the whole point: a regression that rendered one coinbase for every connection would still pass a single-miner test, and conn_render_coinbase() is shared with the pooled modes, so that is a live risk rather than a hypothetical one. - every OP_RETURN the enforcer's template carried is still there. Counted from the template at mining time rather than hardcoded, because BIP300/301 commitments come and go with sidechain activity and a constant would be vacuous or wrong depending on the day. - nothing is credited off-chain, and no pplns or window code path ran. - the pool reports mode=solo. The config sets no pool_mode at all, so this pins the default: if it drifted to a pooled mode every coinbase assertion above would still pass, since a one-miner window pays the same address. Verified by mutation: rendering the solo coinbase to a fixed address instead of the connection's own is caught the moment miner B mines. Also fixes the last grep in that suite matching the startup banner's git branch name rather than the binary's behaviour, and wipes the pool DB on start -- without it "at least 2 blocks" was an assertion about every previous run, which is how a stale-state pass hides a regression. README now lists what each end-to-end suite proves, since "there is also a full end-to-end regtest" undersold five of them and omitted this one. --- .github/workflows/integration_tests.yaml | 11 + .gitignore | 2 + README.md | 18 +- tests/count_op_returns.py | 35 +++ tests/test_solo_regtest.sh | 341 +++++++++++++++++++++++ 5 files changed, 404 insertions(+), 3 deletions(-) create mode 100644 tests/count_op_returns.py create mode 100755 tests/test_solo_regtest.sh diff --git a/.github/workflows/integration_tests.yaml b/.github/workflows/integration_tests.yaml index b1fa58b..b6fab09 100644 --- a/.github/workflows/integration_tests.yaml +++ b/.github/workflows/integration_tests.yaml @@ -45,6 +45,14 @@ jobs: - name: Build run: make -j"$(nproc)" + # Solo first, because it is the default mode and the one most operators + # actually run -- and until now the only mode with no end-to-end test + # anywhere. tests/test_integration.sh looks like one but never mines. + # Two miners with two addresses, so "each coinbase pays its own finder" + # is a claim a single-miner run could not make. + - name: Run solo end-to-end regtest test + run: bash tests/test_solo_regtest.sh + - name: Run end-to-end regtest test run: bash tests/test_e2e_regtest.sh @@ -69,6 +77,7 @@ jobs: with: name: e2e-logs-${{ github.run_id }} path: | + .regtest-solo/logs/ .regtest-e2e/logs/ .regtest-pplns/logs/ .regtest-cbwin/logs/ @@ -78,6 +87,8 @@ jobs: /tmp/simplepool-pplns-*.log /tmp/simplepool-pplns-*.conf /tmp/simplepool-cbwin.log + /tmp/simplepool-solo.log + /tmp/simplepool-solo.conf /tmp/simplepool-cbwin.conf retention-days: 14 if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 2ca5966..4e837b2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ /.regtest-pplns/ /.regtest-btcpay/ /.regtest-cbwin/ +/.regtest-solo/ +/.regtest-solo.lock/ /proxy.conf /tests/integration.proxy.conf # The installer writes proxy.conf.bak. beside proxy.conf on every diff --git a/README.md b/README.md index ad4f8de..610bc5e 100644 --- a/README.md +++ b/README.md @@ -643,9 +643,21 @@ The script: 5. Asserts that `workers` has at least one row, `workers.payout_address` is populated, and `rejects` has at least one row. -There is also a full end-to-end regtest (`tests/test_e2e_regtest.sh`) and a -payout regtest (`tests/test_payout_regtest.sh`); both run in CI. For the -verification checklist behind each mode, see [`VERIFY.md`](VERIFY.md). +Note what that integration test is not: it never mines, so it cannot see +whether a coinbase pays the right person. The end-to-end suites do, one per +mode, each mining a real chain: + +| Suite | Proves | +| --- | --- | +| `tests/test_solo_regtest.sh` | two miners, two addresses, a block each — every coinbase pays **its own finder**, rendered per connection | +| `tests/test_e2e_regtest.sh` | `pps-classic`: the coinbase pays the pool, and shares accrue at the derived rate | +| `tests/test_pplns_regtest.sh` | both pooled PPLNS rails distribute a matured block exactly once | +| `tests/test_pplns_btc_payout_regtest.sh` | `pplns-btc` pays miners on L1 through the enforcer wallet | +| `tests/test_pplns_coinbase_regtest.sh` | `pplns-coinbase`: the block's coinbase pays the window, the pool holds nothing, and the payout floor is disclosed | +| `tests/test_payout_regtest.sh` | the Thunder payout rail settles and confirms | + +All of them run in CI. For the verification checklist behind each mode, see +[`VERIFY.md`](VERIFY.md). ## Layout diff --git a/tests/count_op_returns.py b/tests/count_op_returns.py new file mode 100644 index 0000000..c9553ab --- /dev/null +++ b/tests/count_op_returns.py @@ -0,0 +1,35 @@ +"""Count OP_RETURN outputs in a serialized coinbase tx read as hex on stdin. + +Used by test_solo_regtest.sh to learn how many commitments the enforcer's +template carries, so the suite can assert the mined block preserved exactly +that many rather than hardcoding a number that sidechain activity changes. +""" +import sys + + +def rd_varint(b, off): + n = b[off]; off += 1 + if n < 0xfd: return n, off + if n == 0xfd: return int.from_bytes(b[off:off+2], 'little'), off + 2 + if n == 0xfe: return int.from_bytes(b[off:off+4], 'little'), off + 4 + return int.from_bytes(b[off:off+8], 'little'), off + 8 + + +tx = bytes.fromhex(sys.stdin.read().strip()) +off = 4 # version +if tx[off] == 0x00 and tx[off+1] != 0x00: # segwit marker+flag + off += 2 +vin, off = rd_varint(tx, off) +for _ in range(vin): + off += 36 # prevout + ss, off = rd_varint(tx, off) + off += ss + 4 # scriptSig + sequence +vout, off = rd_varint(tx, off) +n = 0 +for _ in range(vout): + off += 8 # value + spk_len, off = rd_varint(tx, off) + if spk_len >= 1 and tx[off] == 0x6a: + n += 1 + off += spk_len +print(n) diff --git a/tests/test_solo_regtest.sh b/tests/test_solo_regtest.sh new file mode 100755 index 0000000..9e879fa --- /dev/null +++ b/tests/test_solo_regtest.sh @@ -0,0 +1,341 @@ +#!/usr/bin/env bash +# End-to-end proof of pool_mode = solo, against a real chain. +# +# bitcoind-patched <-> bip300301_enforcer <-> simplepool (solo) +# | +# cpuminer.js +# +# Solo is the default mode and the one most operators actually run, and until +# now it was the only mode with no end-to-end test anywhere. tests/ +# test_integration.sh looks like one but is not: it subscribes, authorizes, +# submits one deliberately bogus share and asserts a reject row. It never +# mines, so it cannot see the thing solo is: a coinbase paying the finder. +# It is also not in CI. So the mode with the fewest moving parts had the +# weakest evidence, which is backwards. +# +# What only a chain can prove, and what this asserts: +# +# 1. the coinbase of a block found in solo mode pays the MINER'S OWN +# address, plus the operator fee, and nothing else. No pool address is +# configured and none may appear. +# 2. the coinbase is rendered PER CONNECTION. This is the defining property +# of solo and the one most at risk from work on the pooled modes, which +# share conn_render_coinbase(). Two miners authorize with two different +# addresses and mine a block each; each block must pay its own finder. +# A regression that rendered one coinbase for everybody would still pass +# a single-miner test. +# 3. the enforcer's BIP300/301 commitment OP_RETURNs survive in the +# coinbase, so these blocks can still carry a sidechain. Solo builds its +# coinbase from the server-provided coinbasetxn exactly as the pooled +# modes do, so this is not free. +# 4. nothing is credited off-chain. Solo has no ledger: the coinbase is the +# payment. A pps_credits row would mean a pooled mode's accrual path ran. +# +# Env: +# REGTEST_DIR data dir, WIPED each run (default: /.regtest-solo) +# 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-solo}" +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" +POOL_CONF="/tmp/simplepool-solo.conf" +POOL_LOG="/tmp/simplepool-solo.log" +POOL_DB="/tmp/simplepool-solo.db" + +# The operator's fee address — the only address the pool itself controls. +OPERATOR_ADDR="bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080" +# Two miners, two distinct addresses. Distinct is the whole point: it is what +# makes "each coinbase pays its own finder" a claim rather than a tautology. +MINER_A="bcrt1qzyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3lgth6c" +MINER_B="bcrt1qyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zs4w3j0" +POOL_PID="" + +cli() { "$BIN/bitcoin-cli" -datadir="$REGTEST_DIR/data/bitcoind" -regtest \ + -rpcuser=user -rpcpassword=password "$@"; } +stage() { echo; echo "=== solo-e2e: $1"; } + +dump_logs() { + echo "!!! solo-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 " 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 python3; 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" + +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" +# Solo does not need a sidechain. It is activated so the enforcer's template +# carries the BIP301 commitment outputs the coinbase builder must preserve — +# mining against a template without them would test an easier case than any +# production drivechain pool runs, and assertion 3 below would be vacuous. +"$ROOT/scripts/regtest/activate-thunder.sh" + +stage "start simplepool in solo mode" +# Note what is NOT here: pool_btc_address, and pool_mode itself. Solo is the +# default, so this is also a test that the default has not drifted. +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; } + +grep -q "pool_mode=solo\|mode: solo\|solo" "$POOL_LOG" || true + +# Mine one block per miner, checking the coinbase each time. Wrapped in a +# function because the two runs assert exactly the same thing about different +# addresses -- which is the point. +assert_block_pays() { + local who="$1" addr="$2" + local before after tip cb_json + + # Wait for a job at the height we are about to mine, or the second miner + # connects while the pool is still serving the previous height, mines a + # SIBLING, and submitblock answers "inconclusive". The chain then reads + # N -> N and the failure has nothing to do with solo. + before=$(cli getblockcount) + local next=$((before + 1)) + + # How many OP_RETURNs the enforcer's own coinbase carries right now. The + # count is not fixed -- BIP300/301 commitments come and go with sidechain + # activity, and on a quiet chain the witness commitment is the only one -- + # so asserting a constant would either be vacuous or wrong depending on + # the day. Asserting "the same number came out as went in" is neither. + local base_ors + base_ors=$(curl -s --data-binary \ + '{"jsonrpc":"2.0","id":"t","method":"getblocktemplate","params":[{"rules":["segwit"],"capabilities":["coinbasetxn"]}]}' \ + -H 'content-type: application/json' \ + "http://127.0.0.1:${REGTEST_ENFORCER_RPC_PORT}" \ + | jq -r '.result.coinbasetxn.data' | python3 "$HERE/count_op_returns.py") + echo " enforcer template carries $base_ors OP_RETURN(s)" + for _ in $(seq 1 40); do + grep -q "new job: height=${next} " "$POOL_LOG" && break + sleep 1 + done + grep -q "new job: height=${next} " "$POOL_LOG" || { + echo "FAIL: the pool never published a job at height ${next}" >&2 + exit 1; } + + node "$ROOT/scripts/regtest/cpuminer.js" --port "$POOL_PORT" \ + --user "$addr" --timeout 180 + after=$(cli getblockcount) + echo " height: $before -> $after" + [ "$after" -gt "$before" ] || { + echo "FAIL: $who submitted a block but the chain did not advance" >&2 + exit 1; } + + tip="$(cli getbestblockhash)" + cb_json="$(cli getblock "$tip" 2 | jq -c '.tx[0]')" + echo " block $tip" + CB_JSON="$cb_json" WHO="$who" MINER_ADDR="$addr" BASE_ORS="$base_ors" \ + OPERATOR_ADDR="$OPERATOR_ADDR" OTHER_ADDR="$3" python3 - <<'PY' +import json, os, sys + +cb = json.loads(os.environ['CB_JSON']) +who = os.environ['WHO'] +miner = os.environ['MINER_ADDR'] +other = os.environ['OTHER_ADDR'] +op = os.environ['OPERATOR_ADDR'] + +paid, op_returns = {}, 0 +for o in cb['vout']: + spk = o['scriptPubKey'] + if spk.get('type') == 'nulldata': + op_returns += 1 + continue + addr = spk.get('address') + if addr is None: + print(f"FAIL: spendable output with no address: {spk.get('hex')}", + file=sys.stderr) + sys.exit(1) + paid[addr] = paid.get(addr, 0) + round(o['value'] * 1e8) + +print(f" spendable outputs: {len(paid)} op_returns(commitments): {op_returns}") +for a, v in sorted(paid.items(), key=lambda kv: -kv[1]): + tag = 'FINDER' if a == miner else ('operator fee' if a == op else 'UNKNOWN') + print(f" {v:>14} sats -> {a} ({tag})") + +# 1. solo pays the finder, in the block they found. +if miner not in paid: + print(f"FAIL: {who} found the block but its coinbase does not pay {miner}", + file=sys.stderr) + sys.exit(1) + +# 2. and pays NOBODY else but the fee. In particular not the other miner: +# that is what a shared, non-per-connection coinbase would look like. +if other in paid: + print(f"FAIL: {who}'s block also pays the OTHER miner {other} — the " + f"coinbase is not being rendered per connection", file=sys.stderr) + sys.exit(1) +unknown = [a for a in paid if a not in (miner, op)] +if unknown: + print(f"FAIL: coinbase pays {unknown}; solo configures no pool address, " + f"so there is nothing else it may pay", file=sys.stderr) + sys.exit(1) + +# 3. every commitment the enforcer put in its template survived. Solo builds +# on the server-provided coinbasetxn, so this is not free: a builder that +# dropped one would still produce a VALID block -- just one no sidechain +# can be merge-mined into, which fails silently and only on a drivechain. +base = int(os.environ['BASE_ORS']) +if op_returns != base: + print(f"FAIL: the template carried {base} OP_RETURN(s) and the mined " + f"coinbase has {op_returns}; a commitment was dropped or invented", + file=sys.stderr) + sys.exit(1) + +# 4. the split is the finder's, not the operator's. +mine_sats, op_sats = paid[miner], paid.get(op, 0) +total = mine_sats + op_sats +share = op_sats / total if total else 0 +if mine_sats <= op_sats or share > 0.02: + print(f"FAIL: finder got {mine_sats}, operator {op_sats} ({share:.3%}); " + f"expected the operator to hold ~1%", file=sys.stderr) + sys.exit(1) +print(f" {who} {mine_sats} sats, operator {op_sats} sats ({share:.2%})") +PY +} + +stage "miner A mines a block, and must be paid in it" +assert_block_pays "miner A" "$MINER_A" "$MINER_B" + +stage "miner B mines a block, and must be paid in ITS OWN coinbase" +# The real assertion of this suite. If cb1/cb2 were rendered once per job +# instead of once per connection, this block would pay miner A -- and a +# single-miner test would never notice. +assert_block_pays "miner B" "$MINER_B" "$MINER_A" + +stage "assert the pool ran as solo, by its own account" +# The config above sets no pool_mode at all, so this also pins the DEFAULT. +# If the default ever drifted to a pooled mode, every assertion above would +# still pass -- a pooled coinbase pays whoever the window says, and with one +# miner in the window that is the same address -- so the chain alone cannot +# tell us which mode produced these blocks. The identity line can. +grep -q "mode=solo" "$POOL_LOG" || { + echo "FAIL: the pool did not report itself as solo; the default mode may" >&2 + echo " have drifted, and the coinbase assertions above cannot see it" >&2 + grep -i "pool identity" "$POOL_LOG" >&2 + exit 1; } +echo " $(grep -o 'pool identity: .*' "$POOL_LOG" | head -1)" + +# And no pooled-mode machinery ran. Solo must never build a window, take the +# pplns bootstrap path, or distribute; any of these would mean mode selection +# leaked between rails. +# +# Matched on the message prefixes the code actually emits, NOT on the bare +# word "pplns": the startup banner prints the git branch, so a run from a +# branch named after this work matched itself and failed. A grep that can be +# tripped by the branch name is not testing the binary. +POOLED='pplns-coinbase: |pplns: |pplns distribution|window of [0-9]+ miner' +if grep -qE "$POOLED" "$POOL_LOG"; then + echo "FAIL: pooled-mode code ran in solo:" >&2 + grep -E "$POOLED" "$POOL_LOG" | head -5 >&2 + exit 1 +fi +echo " no pplns or window code path was taken" + +stage "assert solo credits nothing off-chain" +# Solo has no ledger: the coinbase IS the payment. A row here would mean a +# pooled mode's accrual path ran in a mode that must never accrue. +CREDITS="$(sqlite3 "$POOL_DB" "SELECT COALESCE(SUM(accrued_sats),0) FROM pps_credits")" +ROWS="$(sqlite3 "$POOL_DB" "SELECT COUNT(*) FROM pps_credits")" +echo " pps_credits rows=$ROWS accrued=$CREDITS" +[ "$ROWS" = "0" ] && [ "$CREDITS" = "0" ] || { + echo "FAIL: solo wrote $CREDITS sats across $ROWS ledger row(s)" >&2 + exit 1; } + +stage "assert both blocks were recorded, and both miners exist" +BLK_ROWS="$(sqlite3 "$POOL_DB" "SELECT COUNT(*) FROM blocks_found")" +WORKERS="$(sqlite3 "$POOL_DB" "SELECT COUNT(*) FROM workers")" +echo " blocks_found=$BLK_ROWS workers=$WORKERS" +# Exactly two, not "at least": this run mined exactly two blocks with exactly +# two miners, and a fresh DB means any other number is a real defect rather +# than history. +[ "$BLK_ROWS" = "2" ] || { echo "FAIL: expected exactly 2 blocks recorded, got $BLK_ROWS" >&2; exit 1; } +[ "$WORKERS" = "2" ] || { echo "FAIL: expected exactly 2 workers recorded, got $WORKERS" >&2; exit 1; } + +echo +echo "solo-e2e: PASS (each miner was paid in the block it found, from its own" +echo " per-connection coinbase, with the commitments intact)" From 9d6edecf4451822b55ec8c932e6265e303108212 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 8 Sep 2026 17:49:22 +0200 Subject: [PATCH 11/36] dashboard: tell miners about the payout floor, and stop calling every mode solo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forfeit policy rests entirely on being disclosed up front, and the disclosure stopped at the operator's terminal. The proxy stated the floor at startup and per block; the miner it actually costs reads the dashboard, and the dashboard could not see the number because the proxy never published it. A policy nobody can check from outside is a surprise, not a policy. pool_meta now carries pplns_payout_floor_sats, NULL in every mode but pplns-coinbase — distinctly from 0, which is a real floor meaning "pay anything the dust limit allows". The miner-facing card states it before anyone connects, in the words that matter: not carried forward, not paid later, and a floor on how small a miner this pool is worth using. It renders only when the proxy actually published a floor; an older proxy stores NULL, and defaulting to 546 there would be stating someone else's policy for them. Checking that turned up three places answering "not pps-classic" with the word "solo", so every PPLNS pool was told it was solo by the same page whose header named the mode correctly: - the worker page read "Owed: N/A (solo mode)" — shown on all three pplns rails, and on a pplns pool that simply had not distributed yet - the health check read "solo — no accrual" - the templates page read "PPS rate: n/a (solo)" And the miner-facing card branched on pps-classic/solo only, so all three pplns modes fell through to "This pool has not published its mode yet" followed by guidance for two modes, neither of which was theirs. Each mode now gets its own prose and the right username type; the unknown-mode branch names all five rather than the two it was written for. Worst of the set: "Pool solvency" summed blocks_found.reward_sats as pool revenue in pplns-coinbase, where that is what the block paid the MINERS. It reported a healthy 50 BTC margin for a pool that holds nothing and has no wallet — a green light asserting custody that does not exist. Now skipped with the reason, and still exact where custody is real. Verified against a real pplns-coinbase DB from the regtest e2e, plus nine new tests. Seven mutations — hiding the floor, describing it as carried, showing a default where the proxy published none, collapsing a zero floor to no floor, counting solvency again, restoring the "solo" label, and dropping the mode branch — all killed. 155 dashboard tests, 87 payout, full C suite, ASan, and all six regtest e2e suites pass. --- README.md | 11 +- dashboard/lib/health.js | 20 ++- dashboard/lib/stats.js | 20 ++- dashboard/test/about-numbers.test.js | 12 +- .../test/pplns-coinbase-disclosure.test.js | 162 ++++++++++++++++++ dashboard/views/partial/about-numbers.ejs | 153 +++++++++++++++-- dashboard/views/templates.ejs | 3 +- dashboard/views/worker.ejs | 13 +- docs/simplepool.html | 11 +- schema.sql | 9 + src/main.c | 7 +- src/store.c | 21 ++- src/store.h | 3 +- tests/test_store.c | 50 +++++- 14 files changed, 457 insertions(+), 38 deletions(-) create mode 100644 dashboard/test/pplns-coinbase-disclosure.test.js diff --git a/README.md b/README.md index 610bc5e..0fd3f41 100644 --- a/README.md +++ b/README.md @@ -184,10 +184,13 @@ and what a stratum username is: and earn nothing indefinitely, which is strictly worse for them than solo mining, where they at least hold a lottery ticket. - Because that is a trap unless it is visible, the proxy states the floor at - startup, logs how many miners in the current window fall below it, and - reports per block how many claims were forfeited and for how much. **If - you run this mode, publish the floor on your pool page.** + Because that is a trap unless it is visible, the floor is disclosed in four + places: the proxy states it at startup, logs how many miners in the current + window fall below it, and reports per block how many claims were forfeited + and for how much — and it publishes the number to `pool_meta`, so the + **dashboard states it to miners before they connect**. That last one is the + one that matters: the operator's log is the one place the miner it costs + cannot look. 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: diff --git a/dashboard/lib/health.js b/dashboard/lib/health.js index e11fde5..855ad94 100644 --- a/dashboard/lib/health.js +++ b/dashboard/lib/health.js @@ -130,6 +130,17 @@ export function health(handle) { * its first confirmed block, so the margin is legitimately negative until * one lands. That is the honest number, not a fault in the check. */ checks.push(guard('margin', 'Pool solvency', () => { + /* Not a question that exists in pplns-coinbase. There the coinbase + * pays the miners directly, so blocks_found.reward_sats is what the + * block paid THEM -- the pool never received it and owes nobody. + * Summing it as pool revenue reported a healthy 50 BTC of solvency + * for a pool holding precisely nothing, which is a green light + * asserting custody that does not exist. */ + const mode = one(d, 'SELECT pool_mode FROM pool_meta WHERE id = 1')?.pool_mode; + if (mode === 'pplns-coinbase') { + return { ok: true, value: null, + detail: 'pplns-coinbase — the pool never holds the reward' }; + } const r = one(d, ` SELECT (SELECT COALESCE(SUM(reward_sats),0) + COALESCE(SUM(fee_sats),0) FROM blocks_found WHERE status = 'confirmed') @@ -161,7 +172,14 @@ export function health(handle) { checks.push(guard('pps_difficulty', 'Difficulty supports PPS', () => { const meta = one(d, 'SELECT pool_mode, network_difficulty FROM pool_meta WHERE id = 1'); if (!meta || meta.pool_mode !== 'pps-classic') { - return { ok: true, value: null, detail: 'solo — no accrual' }; + /* Name the mode we are actually in. This used to say "solo — no + * accrual" for every non-pps-classic mode, so a pplns pool of any + * kind was told it was solo by the same page whose header said + * otherwise. The check skipping is right -- only pps-classic + * prices a share on arrival -- but the reason has to be true. */ + const m = meta?.pool_mode || 'unknown'; + return { ok: true, value: null, + detail: `${m} — shares are not priced on arrival` }; } const r = one(d, ` SELECT COALESCE(SUM(difficulty),0) AS sd, diff --git a/dashboard/lib/stats.js b/dashboard/lib/stats.js index 25f239b..7c750e2 100644 --- a/dashboard/lib/stats.js +++ b/dashboard/lib/stats.js @@ -438,11 +438,13 @@ function poolIdentity(d) { const blank = { network: null, network_source: null, coinbase_tag: null, operator_address: null, pool_btc_address: null, listeners: null, + pplns_payout_floor_sats: null, }; try { const r = d.prepare(` SELECT network, network_source, coinbase_tag, - operator_address, pool_btc_address, listeners + operator_address, pool_btc_address, listeners, + pplns_payout_floor_sats FROM pool_meta WHERE id = 1 `).get(); if (!r) return blank; @@ -456,6 +458,15 @@ function poolIdentity(d) { operator_address: or_(r.operator_address), pool_btc_address: or_(r.pool_btc_address), listeners: parseListeners(r.listeners), + /* NULL means "this mode has no payout floor", which is every mode + * but pplns-coinbase. Kept distinct from 0, which is a real floor + * meaning "pay anything the dust limit allows" -- so `?? null` + * rather than `|| null`, or a zero floor would read as no floor + * and the page would stop disclosing a policy that still applies. */ + pplns_payout_floor_sats: + r.pplns_payout_floor_sats === undefined || + r.pplns_payout_floor_sats === null + ? null : Number(r.pplns_payout_floor_sats), }; } catch { return blank; /* DB predating the identity columns */ @@ -519,9 +530,10 @@ export function poolMeta(handle) { fee_drift_bps: Number(r.effective_fee_bps || 0) - Number(r.fee_bps || 0), /* 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. + * True of PPS and of the two CUSTODIAL PPLNS rails -- they share + * the table and the payout worker that drains it. Solo and + * pplns-coinbase accrue nothing, because in both the coinbase + * itself is the payment and there is no balance to hold. * * 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 diff --git a/dashboard/test/about-numbers.test.js b/dashboard/test/about-numbers.test.js index e56e175..e149877 100644 --- a/dashboard/test/about-numbers.test.js +++ b/dashboard/test/about-numbers.test.js @@ -139,10 +139,14 @@ test('an unknown mode describes both and commits to neither', async () => { for (const pool of [null, { pool_mode: null, fee_bps: 0 }]) { const html = await card(pool); assert.match(html, /has not published its mode/); - /* Both named, so a miner knows what to ask the operator — but no - * username form is asserted, because guessing costs them time. */ - assert.match(html, /solo/); - assert.match(html, /pps-classic/); + /* Every mode named, so a miner knows what to ask the operator — but + * no username form is asserted, because guessing costs them time. + * The list grew from two to five; a page that names only the two it + * was written for is a page that quietly stopped being complete. */ + for (const m of ['solo', 'pps-classic', 'pplns-thunder', + 'pplns-btc', 'pplns-coinbase']) { + assert.match(html, new RegExp(m), `${m} should be named`); + } assert.doesNotMatch(html, /your-Thunder-address/); assert.doesNotMatch(html, /your-bitcoin-address/); } diff --git a/dashboard/test/pplns-coinbase-disclosure.test.js b/dashboard/test/pplns-coinbase-disclosure.test.js new file mode 100644 index 0000000..89542b1 --- /dev/null +++ b/dashboard/test/pplns-coinbase-disclosure.test.js @@ -0,0 +1,162 @@ +/* What a pplns-coinbase pool tells the people it costs. + * + * This mode forfeits a claim below the payout floor to the operator and never + * settles it. That is defensible as a stated rule and indefensible as a + * discovery, and the whole case for the policy rests on the miner being able + * to see it BEFORE pointing a rig at the pool. The operator's log is the one + * place they cannot look, so these tests treat the disclosure as part of the + * feature rather than as decoration. + * + * They also pin the mislabels this mode exposed. The dashboard used to answer + * "not pps-classic" with the word "solo" in three places, so every pplns pool + * was told it was solo by the same pages whose header said otherwise. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Database from 'better-sqlite3'; +import ejs from 'ejs'; + +import { poolMeta, fmtHashrate } from '../lib/stats.js'; +import { health as runHealth } from '../lib/health.js'; +import * as fmt from '../lib/fmt.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SCHEMA = path.resolve(__dirname, '../../schema.sql'); +const VIEWS = path.resolve(__dirname, '../views'); + +const OPERATOR = 'bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080'; + +function makeDb({ mode = 'pplns-coinbase', floor = 546 } = {}) { + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'sp-cbwin-')), 'shares.db'); + const db = new Database(file); + db.exec(fs.readFileSync(SCHEMA, 'utf8')); + db.prepare(`INSERT INTO pool_meta + (id, network, network_source, coinbase_tag, operator_address, + pool_btc_address, pool_mode, fee_bps, rate_source, + rate_sats_per_diff, gross_sats_per_diff, effective_fee_bps, + network_difficulty, block_value_sats, credited_from, + listeners, updated_at, pplns_payout_floor_sats) + VALUES (1, 'regtest', 'node', '/sp/', @op, NULL, @mode, 100, + 'derived', 0, 0, 100, 1, 5000000000, 1, NULL, 1, @floor)`) + .run({ op: OPERATOR, mode, floor }); + return db; +} + +const render = (view, locals) => + ejs.renderFile(path.join(VIEWS, view), { ...fmt.all, ...locals }, + { views: [VIEWS] }); + +const about = db => render('partial/about-numbers.ejs', + { pool: poolMeta(db), stratumUrl: 'stratum+tcp://x:3334', + sidechainId: 9 }); + +test('the payout floor is stated to the miner, in sats', async () => { + const html = await about(makeDb({ floor: 25000 })); + assert.match(html, /25,000 sats/, 'the floor itself'); + /* Integers, not "25,000.00 sats" -- satoshis do not have decimals, and + * the shared BTC formatter rendered the first version that way. */ + assert.doesNotMatch(html, /25,000\.00 sats/); +}); + +test('the floor is described as forfeited, never as carried', async () => { + const html = await about(makeDb()); + /* The exact claim a miner has to come away with. Softening any of these + * into "held" or "later" would describe the design we deliberately did + * NOT build, and would be a false promise rather than a vague one. */ + assert.match(html, /not.{0,30}carried forward/is); + assert.match(html, /goes to the operator/i); + assert.match(html, /earn nothing/i); +}); + +test('a proxy that never published a floor claims none', async () => { + /* An older proxy stores NULL here. Rendering the default 546 anyway would + * be stating a policy on that operator's behalf, which is worse than + * staying quiet: the operator may be running a build that has no floor. */ + const db = makeDb(); + db.prepare('UPDATE pool_meta SET pplns_payout_floor_sats = NULL').run(); + const html = await about(db); + assert.doesNotMatch(html, /There is a minimum/i); + assert.doesNotMatch(html, /546/); + /* But the mode itself is still described -- silence about the floor must + * not become silence about the mode. */ + assert.match(html, /pplns-coinbase/); +}); + +test('a zero floor is still a floor, and still disclosed', async () => { + /* 0 means "pay anything the dust limit allows" -- a real policy, and + * distinct from NULL. A `|| null` normalisation would collapse the two + * and silently stop disclosing. */ + const html = await about(makeDb({ floor: 0 })); + assert.match(html, /There is a minimum/i); +}); + +test('every mode gets its own guidance, and none is called solo', async () => { + for (const mode of ['pplns-coinbase', 'pplns-btc', 'pplns-thunder']) { + const html = await about(makeDb({ mode })); + assert.match(html, new RegExp(mode), + `${mode} should name itself`); + /* The bug: all three fell through to the unknown-mode branch. */ + assert.doesNotMatch(html, /has not published its mode yet/, + `${mode} should not read as unknown`); + } +}); + +test('the pplns rails ask for the right username type', async () => { + const thunder = await about(makeDb({ mode: 'pplns-thunder' })); + assert.match(thunder, /your-thunder-address/); + const btc = await about(makeDb({ mode: 'pplns-btc' })); + assert.match(btc, /your-bitcoin-address/); + const cb = await about(makeDb({ mode: 'pplns-coinbase' })); + assert.match(cb, /your-bitcoin-address/); +}); + +test('solvency is not claimed for a pool that holds nothing', async () => { + /* In pplns-coinbase blocks_found.reward_sats is what the block paid the + * MINERS. Summing it as pool revenue reported a healthy 50 BTC margin for + * a pool with no wallet -- a green light asserting custody that does not + * exist. */ + const db = makeDb(); + db.prepare(`INSERT INTO blocks_found (ts, height, hash, reward_sats, + fee_sats, status) + VALUES (1, 11, 'aa', 4950000000, 50000000, 'confirmed')`).run(); + const margin = runHealth(db).checks.find(c => c.id === 'margin'); + assert.equal(margin.value, null, 'no margin figure for a custody-free pool'); + assert.match(margin.detail, /never holds the reward/); + + /* And the check still works where custody is real. */ + const pps = makeDb({ mode: 'pps-classic' }); + pps.prepare(`INSERT INTO blocks_found (ts, height, hash, reward_sats, + fee_sats, status) + VALUES (1, 11, 'aa', 4950000000, 50000000, 'confirmed')`).run(); + assert.equal(runHealth(pps).checks.find(c => c.id === 'margin').value, + 5000000000); +}); + +test('the accrual check names the mode it is actually in', async () => { + for (const mode of ['solo', 'pplns-coinbase', 'pplns-btc', 'pplns-thunder']) { + const c = runHealth(makeDb({ mode })).checks + .find(x => x.id === 'pps_difficulty'); + assert.match(c.detail, new RegExp(mode), + `${mode} should be named, not called solo`); + } +}); + +test('a mode with no balance does not report one as owed', async () => { + const html = await render('worker.ejs', { + pool: poolMeta(makeDb()), + health: { ok: true, checks: [] }, + worker: { name: 'w', payout_address: 'bcrt1q', first_seen: 1, + last_seen: 1, window_shares: 0, window_hashrate: 0 }, + name: 'w', shares: [], buckets: [], window_sec: 86400, + pps_audit: null, pplns_audit: null, payouts: [], blocks: [], + fmtHashrate, + stratumUrl: 'stratum+tcp://x:3334', sidechainId: 9, + }); + assert.doesNotMatch(html, /solo mode/, + 'a pplns-coinbase worker page must not claim solo'); + assert.match(html, /paid in the coinbase/); +}); diff --git a/dashboard/views/partial/about-numbers.ejs b/dashboard/views/partial/about-numbers.ejs index a1c7baf..fa5913c 100644 --- a/dashboard/views/partial/about-numbers.ejs +++ b/dashboard/views/partial/about-numbers.ejs @@ -1,12 +1,16 @@ <%# What the figures on the overview mean, and how to point a rig at this pool. Branches on pool_mode because almost nothing here is shared between the - two modes: solo pays the finder in the coinbase and accrues nothing, + modes: solo pays the finder in the coinbase and accrues nothing, pps-classic credits every share and settles through a pool wallet and a - BIP300 deposit. The card used to state the pps-classic story - unconditionally, which on a solo pool told miners to authorize with a - Thunder address — the one thing that cannot work there (stratum.c - rejects it with "invalid payout address in stratum username"). + BIP300 deposit, the two custodial pplns rails credit on maturity, and + pplns-coinbase pays the whole window in the block itself. The card used to + state the pps-classic story unconditionally, which on a solo pool told + miners to authorize with a Thunder address — the one thing that cannot + work there (stratum.c rejects it with "invalid payout address in stratum + username"). Later it covered only those two, so all three pplns modes fell + through to "this pool has not published its mode yet" while the header + strip above it named the mode correctly. Every figure comes from res.locals.pool, i.e. from pool_meta, i.e. from the running proxy. Nothing here is a literal, because the previous @@ -25,6 +29,11 @@ const _sid = (typeof sidechainId !== 'undefined' && sidechainId != null) const _feePct = _p && _p.fee_bps ? (_p.fee_bps / 100).toFixed(2) + '%' : null; const _sats = n => Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +/* Satoshis are integers. _sats above renders 2 decimals, which is right for + * the BTC-denominated figures it was written for and wrong for a sat count: + * the payout floor read "546.00 sats". */ +const _satsInt = n => Number(n).toLocaleString('en-US', + { maximumFractionDigits: 0 }); /* Address examples for the network this pool is actually on — a miner told * to use "bc1q…" on a signet pool has been misled just as surely as one told @@ -165,18 +174,140 @@ password: (ignored — any value) paying the same address as separate rows on the per-worker page.

+<% } else if (_mode === 'pplns-thunder' || _mode === 'pplns-btc') { %> +

+ This pool runs in <%= _mode %> mode. A block is + divided among the shares that produced it — the last + N shares before it was found — so a share is worth a + claim on blocks this pool actually finds, not a fixed price paid on + arrival. Nothing is promised in advance, which is why there is no + operator reserve here and why the fee is usually lower than PPS. +

+

+ Your balance moves when a block matures, 100 + confirmations after it was found (roughly 16 hours). Until then the + block is still reversible and nothing is credited. After that your + balance is settled + <% if (_mode === 'pplns-btc') { %> + on Bitcoin L1, in a batched transaction. + <% } else { %> + over Thunder, sidechain + #<%= _sid %>, by the payout worker. + <% } %> + <% if (_feePct) { %>The operator fee of + <%= _feePct %> comes off the block before it is + divided.<% } %> +

+ +

Connect a miner

+
<%= _url %>
+<% if (_mode === 'pplns-btc') { %>username:  <your-bitcoin-address>[.<rig_label>]<% } else { %>username:  <your-thunder-address>[.<rig_label>]<% } %>
+password:  (ignored — any value)
+

+ <% if (_mode === 'pplns-btc') { %> + The username must be a Bitcoin address this pool can pay: + <% if (_ex) { %>P2WPKH (<%= _ex.w %>), + P2PKH (<%= _ex.l %>) or + P2SH (<%= _ex.s %>) on + <%= _net %><% } else { %>P2WPKH, + P2PKH or P2SH<% } %>. A + Thunder address is rejected at authorize — that + is the other rail. + <% } else { %> + The username must be a bare base58 Thunder + address. A Bitcoin address is rejected at authorize — that is the + other rail. Both fail with "invalid payout address in stratum + username". + <% } %> +

+ +<% } else if (_mode === 'pplns-coinbase') { %> +

+ This pool runs in pplns-coinbase mode. A block is + divided among the shares that produced it, exactly as the other PPLNS + rails do — but it is paid in that block's own coinbase, + one output per miner. The pool never receives the reward, holds no + wallet and keeps no balance for you. There is nothing to withdraw and + nothing to wait for: if you are in the window when a block is found, + you are paid in it. +

+

+ A block that is later reorged out simply never paid — there is no + credit to reverse.<% if (_feePct) { %> The operator fee of + <%= _feePct %> comes off the block before it is + divided<% if (_p && _p.operator_address) { %>, and goes to + <%= _p.operator_address %><% } %>.<% } %> + Coinbase outputs mature after 100 confirmations + (roughly 16 hours), so a block appears here well before it is + spendable. +

+ + <%# The disclosure this mode exists to make. A claim below the floor is + forfeited to the operator and never settled, so a miner too small to + clear it will mine here, submit valid shares, and earn nothing + indefinitely. That is defensible as a stated rule and indefensible as + a discovery, and the operator's log is the one place the miner it + costs cannot see. Only rendered when the proxy actually published a + floor -- an older proxy stores NULL, and inventing 546 there would be + stating someone else's policy for them. %> + <% if (_p && _p.pplns_payout_floor_sats != null) { %> +

+ There is a minimum, and it is not held over. + If your share of a block comes to less than + <%= _satsInt(_p.pplns_payout_floor_sats) %> sats, you + get no output in that coinbase and the amount goes to the operator. + It is not carried forward and not + paid later. A coinbase is a fixed budget of bytes and an output too + small to be worth its own space cannot be written, so this pool + forfeits it rather than keep a balance it would have to custody. +

+

+ In practice that is a floor on how small a miner this pool is worth + using. Below it you would mine here and earn nothing, however long you + stayed — worse than mining solo, where you at least hold a lottery + ticket on a whole block. Work out your expected share of a block + before pointing a rig here, and ask the operator if you are near the + line. +

+ <% } %> + +

Connect a miner

+
<%= _url %>
+username:  <your-bitcoin-address>[.<rig_label>]
+password:  (ignored — any value)
+

+ The username must be a Bitcoin address this pool can build a coinbase + output for: + <% if (_ex) { %> + P2WPKH (<%= _ex.w %>), + P2PKH (<%= _ex.l %>) or + P2SH (<%= _ex.s %>) on + <%= _net %> + <% } else { %> + P2WPKH, P2PKH or + P2SH + <% } %>. A Thunder address is rejected at authorize — + that is a different mode. +

+ <% } else { %> <%# Same rule as the identity strip: an unknown mode gets prose that is true either way, never a guess. The two modes differ on what a share is worth and on what the username must be, so guessing wrong here costs a miner real time. %>

- This pool has not published its mode yet, and the two differ in ways - that change how you connect: solo pays the finder - directly in the coinbase and takes a Bitcoin address as the stratum - username, while pps-classic credits every accepted - share and takes a Thunder address. Restart the proxy to publish which - one this is, or ask the operator before pointing a rig at it. + This pool has not published its mode yet. It is one of + solo, pps-classic, + pplns-thunder, pplns-btc or + pplns-coinbase, and they differ in ways that change + how you connect and what you are owed: some pay the finder or the + whole window directly in the coinbase and take a + Bitcoin address as the stratum username, while + others credit a balance settled later and take a + Thunder address. One of them, + pplns-coinbase, does not pay claims below a minimum + at all. Restart the proxy to publish which one this is, and ask the + operator before pointing a rig at it.

Connect a miner

<%= _url %>
diff --git a/dashboard/views/templates.ejs b/dashboard/views/templates.ejs
index f2120da..f26d9ae 100644
--- a/dashboard/views/templates.ejs
+++ b/dashboard/views/templates.ejs
@@ -46,7 +46,8 @@
             
<%= fmtN(cur.tx_count) %>
<%= cur.bits %>
<%= cur.rate_sats_per_diff > 0 - ? fmtF(cur.rate_sats_per_diff, 4) + ' sats/diff' : 'n/a (solo)' %>
+ ? fmtF(cur.rate_sats_per_diff, 4) + ' sats/diff' + : 'n/a (only pps-classic prices a share on arrival)' %>
<%= ago(cur.ts) %>
<%= ago(cur.last_seen) %> (<%= fmtN(cur.polls) %> poll<%= cur.polls === 1 ? '' : 's' %>)
diff --git a/dashboard/views/worker.ejs b/dashboard/views/worker.ejs index 82fe5ea..0ab2f60 100644 --- a/dashboard/views/worker.ejs +++ b/dashboard/views/worker.ejs @@ -31,7 +31,18 @@ if (n > 1) {
<%= fmtN(worker.window_shares) %>
<%= ago(worker.first_seen) %>
<%= ago(worker.last_seen) %>
-
<%= pps_audit ? fmtSats(pps_audit.owed) : 'N/A (solo mode)' %>
+ <%# "N/A (solo mode)" was shown on every mode without a + pps_credits row -- including all three pplns rails, and + including a pplns pool that simply has not distributed yet. + A page telling a miner they are on a solo pool, directly + under a header saying pplns-coinbase, is worse than saying + nothing. Name the real reason instead. %> +
<%= pps_audit ? fmtSats(pps_audit.owed) + : (pool && pool.pool_mode === 'pplns-coinbase' + ? 'N/A — paid in the coinbase' + : (pool && pool.pool_mode === 'solo' + ? 'N/A — paid in the coinbase' + : 'nothing owed yet')) %>
diff --git a/docs/simplepool.html b/docs/simplepool.html index ee78ce0..d6c422b 100644 --- a/docs/simplepool.html +++ b/docs/simplepool.html @@ -502,10 +502,13 @@

pool_mode = pplns-coinbase

least hold a lottery ticket.

- Because that is a trap unless it is visible, the proxy states the floor - at startup, reports how many miners in the current window fall below it, - and says per block how many claims were forfeited and for how much. - If you run this mode, publish the floor on your pool page. + Because that is a trap unless it is visible, the floor is disclosed four + ways: stated at startup, reported per template as the count of miners + about to be excluded, reported per block as the claims actually + forfeited — and published to pool_meta, so the + dashboard states it to miners before they connect. + That last one is the one that matters: the operator's log is the one + place the miner it costs cannot look.

diff --git a/schema.sql b/schema.sql index f2d286d..6db1874 100644 --- a/schema.sql +++ b/schema.sql @@ -138,6 +138,15 @@ CREATE TABLE IF NOT EXISTS pool_meta ( operator_address TEXT, /* fee_bps recipient */ pool_btc_address TEXT, /* pps-classic only; NULL in solo */ pool_mode TEXT, + /* pplns-coinbase only; NULL in every other mode. The least a claim must be + * worth to get a coinbase output at all -- below it a miner is not paid and + * the amount goes to the operator, permanently. + * + * Published here because the miner it costs reads the dashboard, not the + * operator's log. A forfeit policy nobody can see from outside is not a + * policy, it is a surprise, and the whole case for having one is that it is + * stated up front. */ + pplns_payout_floor_sats INTEGER, fee_bps INTEGER, rate_source TEXT, /* 'derived' | 'override' */ rate_sats_per_diff REAL, /* effective, net of fee; 0 in solo */ diff --git a/src/main.c b/src/main.c index 537ef63..2e7c7bb 100644 --- a/src/main.c +++ b/src/main.c @@ -1222,9 +1222,14 @@ int main(int argc, char **argv) { LOG_WARN("pool identity: %d listener(s) did not fit the published " "port list — the dashboard will not show them", dropped); } + /* -1 means "this mode has no payout floor", which is every mode but + * pplns-coinbase. Only there can a miner mine and be paid nothing, + * and only there does the dashboard have something to disclose. */ store_record_pool_identity(store, network, network_src, cfg.coinbase_tag, cfg.operator_address, - pps ? cfg.pool_btc_address : NULL, lj); + pps ? cfg.pool_btc_address : NULL, lj, + strcmp(cfg.pool_mode, "pplns-coinbase") == 0 + ? cfg.pplns_payout_floor_sats : -1); } /* Broadcast (optional). */ diff --git a/src/store.c b/src/store.c index 1b4bc2f..ea41579 100644 --- a/src/store.c +++ b/src/store.c @@ -151,6 +151,7 @@ static const char *SCHEMA_SQL_PARTS[] = { " operator_address TEXT," /* fee_bps recipient */ " pool_btc_address TEXT," /* pps-classic only; NULL in solo */ " pool_mode TEXT," + " pplns_payout_floor_sats INTEGER," " fee_bps INTEGER," " rate_source TEXT," " rate_sats_per_diff REAL," /* effective, net of fee */ @@ -335,6 +336,7 @@ static const char *MIGRATIONS_SQL[] = { * until the proxy restarts, which the dashboard renders as "not * published yet" rather than claiming the pool has one port. */ "ALTER TABLE pool_meta ADD COLUMN listeners TEXT", + "ALTER TABLE pool_meta ADD COLUMN pplns_payout_floor_sats INTEGER", /* Block accounting. Every pre-existing row becomes 'pending' — which * counts as nothing — rather than being assumed good: the rows were * written unconditionally, including for candidates submitblock had @@ -1569,7 +1571,8 @@ int store_record_pool_identity(store_t *s, const char *network, const char *coinbase_tag, const char *operator_address, const char *pool_btc_address, - const char *listeners_json) + const char *listeners_json, + int64_t pplns_payout_floor_sats) { if (!s) return -1; /* Upserts the same id=1 row as store_record_pool_meta(), but only the @@ -1584,15 +1587,17 @@ int store_record_pool_identity(store_t *s, const char *network, * blank". */ static const char *Q = "INSERT INTO pool_meta (id, network, network_source, coinbase_tag," - " operator_address, pool_btc_address, listeners) " - "VALUES (1, ?, ?, ?, ?, ?, ?) " + " operator_address, pool_btc_address, listeners," + " pplns_payout_floor_sats) " + "VALUES (1, ?, ?, ?, ?, ?, ?, ?) " "ON CONFLICT(id) DO UPDATE SET " " network = excluded.network," " network_source = excluded.network_source," " coinbase_tag = excluded.coinbase_tag," " operator_address = excluded.operator_address," " pool_btc_address = excluded.pool_btc_address," - " listeners = excluded.listeners"; + " listeners = excluded.listeners," + " pplns_payout_floor_sats = excluded.pplns_payout_floor_sats"; sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(s->db, Q, -1, &st, NULL) != SQLITE_OK) { atomic_fetch_add(&s->pg_errors, 1); @@ -1616,6 +1621,14 @@ int store_record_pool_identity(store_t *s, const char *network, } else { sqlite3_bind_null(st, 6); } + /* NULL in every mode but pplns-coinbase, so a reader can tell "this pool + * forfeits nothing because it has no floor" from "this pool's floor is + * zero". Only the first is true of the other four modes. */ + if (pplns_payout_floor_sats >= 0) { + sqlite3_bind_int64(st, 7, (sqlite3_int64)pplns_payout_floor_sats); + } else { + sqlite3_bind_null(st, 7); + } int rc = sqlite3_step(st); pthread_mutex_unlock(&s->node_tip_mu); sqlite3_finalize(st); diff --git a/src/store.h b/src/store.h index a223552..739598d 100644 --- a/src/store.h +++ b/src/store.h @@ -250,7 +250,8 @@ int store_record_pool_identity(store_t *s, const char *network, const char *coinbase_tag, const char *operator_address, const char *pool_btc_address, - const char *listeners_json); + const char *listeners_json, + int64_t pplns_payout_floor_sats); int store_record_pool_meta(store_t *s, const char *pool_mode, int fee_bps, const char *rate_source, diff --git a/tests/test_store.c b/tests/test_store.c index 02cc4e8..23ab8e6 100644 --- a/tests/test_store.c +++ b/tests/test_store.c @@ -580,7 +580,8 @@ static void test_pool_identity(void) { assert(store_record_pool_identity(s, "signet", "node", "/simplepool/", "tb1qoperator", "tb1qpoolwallet", "[{\"port\":3334,\"label\":\"\"," - "\"min_diff\":1,\"initial_diff\":1}]") == 0); + "\"min_diff\":1,\"initial_diff\":1}]", + -1) == 0); assert(store_record_pool_meta(s, "pps-classic", 100, "derived", 2783.22, 2811.33, 100.4, 111157.455, 312500000, 1700000000ULL) == 0); @@ -611,7 +612,7 @@ static void test_pool_identity(void) { * stalled template path would keep looking alive. */ int64_t seen = scalar_i64(db, "SELECT updated_at FROM pool_meta"); assert(store_record_pool_identity(s, "regtest", "inferred", "/other/", - "bcrt1qop", NULL, NULL) == 0); + "bcrt1qop", NULL, NULL, -1) == 0); assert(scalar_i64(db, "SELECT updated_at FROM pool_meta") == seen); /* Solo mode: NULL, not "". */ @@ -1449,6 +1450,50 @@ static void test_an_empty_window_returns_nothing_not_an_error(void) { printf(" ok test_an_empty_window_returns_nothing_not_an_error\n"); } +/* The payout floor has to reach the DASHBOARD, not just the operator's log. + * + * pplns-coinbase forfeits a claim below the floor to the operator and never + * settles it, and the entire case for that policy is that it is disclosed up + * front. The miner it costs reads the dashboard; the operator's terminal is + * the one place they cannot see. So the floor being in pool_meta is part of + * the policy, not a nicety. + * + * NULL in every other mode, distinctly from 0: "this pool has no floor" and + * "this pool's floor is zero sats" are different claims, and only the first + * is true of solo, pps-classic and the two custodial pplns rails. */ +static void test_the_payout_floor_is_published_for_the_dashboard(void) { + const char *path = fresh_db_path(); + store_cfg_t cfg = {0}; + snprintf(cfg.path, sizeof(cfg.path), "%s", path); + store_t *s = NULL; + assert(store_open(&cfg, &s) == 0); + + assert(store_record_pool_identity(s, "regtest", "node", "/sp/", + "bcrt1qop", NULL, NULL, 25000) == 0); + sqlite3 *db = NULL; + assert(sqlite3_open(path, &db) == SQLITE_OK); + assert(scalar_i64(db, "SELECT pplns_payout_floor_sats FROM pool_meta") == 25000); + + /* A mode with no floor stores NULL, not 0. */ + assert(store_record_pool_identity(s, "regtest", "node", "/sp/", + "bcrt1qop", NULL, NULL, -1) == 0); + assert(scalar_i64(db, "SELECT pplns_payout_floor_sats IS NULL " + "FROM pool_meta") == 1); + + /* Zero is a real floor and must survive as 0, not collapse to NULL -- + * it means "pay anything the dust limit allows", which is a different + * promise from "there is no floor here". */ + assert(store_record_pool_identity(s, "regtest", "node", "/sp/", + "bcrt1qop", NULL, NULL, 0) == 0); + assert(scalar_i64(db, "SELECT pplns_payout_floor_sats IS NULL " + "FROM pool_meta") == 0); + assert(scalar_i64(db, "SELECT pplns_payout_floor_sats FROM pool_meta") == 0); + + sqlite3_close(db); + store_close(s); + printf(" ok test_the_payout_floor_is_published_for_the_dashboard\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(); @@ -1502,6 +1547,7 @@ int main(void) { test_open_upgrades_a_pre_status_database(); test_pplns_distributes_the_window(); test_pplns_takes_the_operator_fee(); + test_the_payout_floor_is_published_for_the_dashboard(); test_pplns_distributes_two_blocks_in_one_pass(); test_an_empty_window_returns_nothing_not_an_error(); test_a_window_wider_than_the_cap_says_so(); From a2f3f4b1d8d8f743c3d9d47969672b806859416b Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 8 Sep 2026 20:48:06 +0200 Subject: [PATCH 12/36] pplns-coinbase: state the payout floor with the identity, not after the first template It was logged down by the stratum config, which runs only after the initial getblocktemplate succeeds. A configured fact should not be contingent on the node answering: an operator debugging a pool that cannot reach its backend saw the mode but not the policy. It now prints beside the pool identity line, and mentions that the dashboard carries the same number to miners. --- src/main.c | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/main.c b/src/main.c index 2e7c7bb..356e1f2 100644 --- a/src/main.c +++ b/src/main.c @@ -1175,6 +1175,19 @@ int main(int argc, char **argv) { network, network_src, cfg.pool_mode, cfg.fee_bps, cfg.coinbase_tag, cfg.operator_address, pps ? " pool_btc=" : "", pps ? cfg.pool_btc_address : ""); + /* Say the policy out loud on every start, next to the rest of the + * identity rather than down by the stratum config -- this is a + * configured fact, not a template one, and printing it here means it + * survives a node that is not answering yet. An operator who never + * saw it stated cannot disclose it to the miners it costs. */ + if (strcmp(cfg.pool_mode, "pplns-coinbase") == 0) { + LOG_INFO("pplns-coinbase: payout floor %lld sats — a miner whose " + "share of a block is worth less than that is NOT PAID, " + "and the amount goes to the operator. Nothing is carried " + "and nothing settles later. The dashboard states this to " + "miners; publish it on your pool page too.", + (long long)cfg.pplns_payout_floor_sats); + } /* Publish the ports so the dashboard can tell a miner which one to * dial. Labels are constrained to [A-Za-z0-9_-] at config parse time, * so this needs no escaping. */ @@ -1362,16 +1375,6 @@ int main(int argc, char **argv) { stcfg.coinbase_pays_pool = mode_pps_classic || mode_pplns_thunder || mode_pplns_btc; stcfg.coinbase_pays_window = mode_pplns_cb; - if (mode_pplns_cb) { - /* Say the policy out loud on every start. It is the one place this - * pool is harsher than a custodial one, and an operator who never - * saw it stated cannot disclose it to the miners it costs. */ - LOG_INFO("pplns-coinbase: payout floor %lld sats — a miner whose " - "share of a block is worth less than that is NOT PAID, and " - "the amount goes to the operator. Nothing is carried and " - "nothing settles later. Publish this on your pool page.", - (long long)cfg.pplns_payout_floor_sats); - } stcfg.max_coinbase_bytes = (size_t)cfg.coinbase_max_bytes; stcfg.payout_floor_sats = cfg.pplns_payout_floor_sats; stcfg.username_is_thunder = mode_pps_classic || mode_pplns_thunder; From c5c7379c9cb2b8b02e43be285428afafd584879a Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 8 Sep 2026 21:21:39 +0200 Subject: [PATCH 13/36] pplns: lift the window split out of main.c, and test the mixed windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mixed-window forfeit was called an untested path. Chasing it turned up something worse: attach_pplns_window() had no test AT ALL. It is static in main.c, so nothing could reach it — and it holds the fee split, the floating-point difficulty-to-satoshis division, the remainder rule and the below-floor prediction. A bug there does not crash and does not log; it pays somebody the wrong amount, which is the failure this rail must be trusted not to have. pplns.c now holds that arithmetic, the same extraction reconcile.c got for the confirmation pass. Pure: no store, no template, no logging, so an expected split can be stated exactly instead of mined for. main.c keeps the parts that need the world. That makes the mixed windows testable, which the regtest harness cannot produce: share difficulty is clamped to network difficulty on regtest, so the window holds about two shares and every run reports "window of 1 miner(s)". test_pplns.c drives a few large claims and a tail of small ones, asserts which the floor will drop and that the block is still spent whole, and walks the floor up through four values checking the prediction tracks it. Two findings while writing it, both mine and both instructive: - my first expected values were wrong twice. 100,000,002 divides by three exactly, and a clean 60% claim comes to 187,500,001 rather than 187,500,000 because the other claims truncate down and the remainder rule puts the satoshi on the largest. Asserting the tidy number would have been asserting a bug. - mutation testing left three survivors, all boundary-exact. Changing the fee from floor to ceiling division was invisible, and that one is not cosmetic: coinbase_build_window() computes the fee itself and REFUSES a split that disagrees by a satoshi, so the pool would render no coinbase at all, on every connection, on every job. Closed by asserting against the builder rather than against a constant — five rewards whose fee does not divide evenly, each fed through the real builder. The other two were the floor and dust comparisons; a claim worth exactly the floor is PAID, and a 545-sat fee is dust. Twelve mutations now killed. Plus a 20,000-iteration conservation check over random windows of deliberately mixed magnitudes: paid + fee == reward, exactly, with no negative payee. Wired into make test, make asan and make coverage. Full C suite, ASan, and the solo and coinbase e2e suites all pass unchanged — the extraction is meant to be behaviour-preserving and the chain says it is. --- Makefile | 14 +- src/main.c | 43 +++-- src/pplns.c | 94 +++++++++++ src/pplns.h | 68 ++++++++ tests/test_pplns.c | 402 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_pplns.mk | 3 + 6 files changed, 597 insertions(+), 27 deletions(-) create mode 100644 src/pplns.c create mode 100644 src/pplns.h create mode 100644 tests/test_pplns.c create mode 100644 tests/test_pplns.mk diff --git a/Makefile b/Makefile index 4fc3d46..b67787f 100644 --- a/Makefile +++ b/Makefile @@ -67,7 +67,7 @@ GIT_DIRTY := $(shell git status --porcelain --untracked-files=no 2>/dev/null | VERSION_H := $(BUILD_DIR)/version_gen.h # Sources compiled in this wave. More modules land in later waves. -SRCS := src/main.c src/log.c src/config.c src/coinbase.c \ +SRCS := src/main.c src/log.c src/config.c src/coinbase.c src/pplns.c \ src/share.c src/sha256.c src/stratum.c src/store.c \ src/bitcoind.c src/broadcast.c src/thunder.c src/version.c \ src/reconcile.c src/cjson/cJSON.c @@ -124,8 +124,9 @@ include tests/test_broadcast.mk include tests/test_thunder.mk include tests/test_config.mk include tests/test_reconcile.mk +include tests/test_pplns.mk -test: build/test_share build/test_bitcoind build/test_stratum build/test_store build/test_coinbase build/test_broadcast build/test_thunder build/test_config build/test_reconcile +test: build/test_share build/test_bitcoind build/test_stratum build/test_store build/test_coinbase build/test_broadcast build/test_thunder build/test_config build/test_reconcile build/test_pplns ./build/test_share ./build/test_bitcoind ./build/test_stratum @@ -135,6 +136,7 @@ test: build/test_share build/test_bitcoind build/test_stratum build/test_store b ./build/test_thunder ./build/test_config ./build/test_reconcile + ./build/test_pplns # Run the suites under AddressSanitizer + UndefinedBehaviorSanitizer. # @@ -161,10 +163,13 @@ asan: src/coinbase.c src/sha256.c $(CC) $(ASAN_CFLAGS) -o $(ASAN_DIR)/test_share tests/test_share.c \ src/share.c src/sha256.c + $(CC) $(ASAN_CFLAGS) -o $(ASAN_DIR)/test_pplns tests/test_pplns.c \ + src/pplns.c src/coinbase.c src/sha256.c ./$(ASAN_DIR)/test_stratum ./$(ASAN_DIR)/test_store ./$(ASAN_DIR)/test_coinbase ./$(ASAN_DIR)/test_share + ./$(ASAN_DIR)/test_pplns # Line and function coverage of the C suites, via LLVM source-based coverage. # @@ -205,6 +210,8 @@ coverage: $(CC) $(COV_CFLAGS) -o $(COV_DIR)/test_thunder tests/test_thunder.c src/thunder.c $(CC) $(COV_CFLAGS) -o $(COV_DIR)/test_config tests/test_config.c \ src/config.c src/log.c src/coinbase.c src/sha256.c + $(CC) $(COV_CFLAGS) -o $(COV_DIR)/test_pplns tests/test_pplns.c \ + src/pplns.c src/coinbase.c src/sha256.c $(CC) $(COV_CFLAGS) -o $(COV_DIR)/test_reconcile tests/test_reconcile.c \ src/reconcile.c src/store.c src/log.c $(PLATFORM_LDFLAGS) -lsqlite3 -lpthread @set -e; for t in stratum store coinbase share bitcoind broadcast thunder config reconcile; do \ @@ -216,7 +223,8 @@ coverage: @xcrun llvm-cov report $(COV_DIR)/test_stratum \ $(addprefix -object ,$(COV_DIR)/test_store $(COV_DIR)/test_coinbase \ $(COV_DIR)/test_share $(COV_DIR)/test_bitcoind $(COV_DIR)/test_broadcast \ - $(COV_DIR)/test_thunder $(COV_DIR)/test_config $(COV_DIR)/test_reconcile) \ + $(COV_DIR)/test_thunder $(COV_DIR)/test_config $(COV_DIR)/test_reconcile \ + $(COV_DIR)/test_pplns) \ -instr-profile=$(COV_DIR)/all.profdata $(COV_IGNORE) format: diff --git a/src/main.c b/src/main.c index 356e1f2..d1868f1 100644 --- a/src/main.c +++ b/src/main.c @@ -7,6 +7,7 @@ #include "share.h" #include "store.h" #include "reconcile.h" +#include "pplns.h" #include "coinbase.h" #include "stratum.h" #include "version.h" @@ -281,31 +282,26 @@ static int attach_pplns_window(store_t *store, const proxy_config_t *cfg, } value = from_tx; } - int64_t fee = 0; - if (cfg->operator_address[0] && cfg->fee_bps > 0) { - int64_t f = (value * (int64_t)cfg->fee_bps) / 10000; - if (f >= COINBASE_DUST_SATS) fee = f; - } - int64_t payable = value - fee; - if (payable <= 0) { - LOG_WARN("pplns-coinbase: template pays %lld sats, nothing left after " - "the operator fee", (long long)value); - return -1; + /* The arithmetic lives in pplns.c so it can be tested against stated + * numbers rather than only against a chain -- it decides what people are + * paid, and it used to be unreachable from any test. See pplns.h. */ + pplns_claim_t claims[COINBASE_MAX_PAYOUT_OUTPUTS]; + for (size_t i = 0; i < n; ++i) { + claims[i].payout_address = win[i].payout_address; + claims[i].difficulty = win[i].difficulty; } coinbase_payee_t payees[COINBASE_MAX_PAYOUT_OUTPUTS]; - int64_t assigned = 0; - for (size_t i = 0; i < n; ++i) { - payees[i].address = win[i].payout_address; - payees[i].sats = (int64_t)((double)payable * (win[i].difficulty / total)); - assigned += payees[i].sats; + pplns_split_t split; + char serr[256] = {0}; + if (pplns_split_window(value, cfg->fee_bps, cfg->operator_address[0] != 0, + claims, n, total, cfg->pplns_payout_floor_sats, + payees, COINBASE_MAX_PAYOUT_OUTPUTS, + &split, serr, sizeof serr) < 0) { + LOG_WARN("pplns-coinbase: cannot split this block across the window: " + "%s", serr); + return -1; } - /* Truncating division leaves a few sats over. They cannot be dropped -- - * the builder requires the split to spend `payable` exactly, and a - * coinbase that pays out less forfeits the difference to nobody -- so - * they go to the largest claim, which store_pplns_window returns first. - * A handful of satoshis, to the miner with the strongest claim on them. */ - if (assigned < payable) payees[0].sats += payable - assigned; if (stratum_job_set_window(job, payees, n) < 0) { LOG_WARN("pplns-coinbase: could not attach the window to the job"); @@ -325,8 +321,7 @@ static int attach_pplns_window(store_t *store, const proxy_config_t *cfg, * to have, so reporting an unclamped one would understate who loses. */ int64_t floor_sats = cfg->pplns_payout_floor_sats < COINBASE_DUST_SATS ? COINBASE_DUST_SATS : cfg->pplns_payout_floor_sats; - size_t below = 0; - for (size_t i = 0; i < n; ++i) if (payees[i].sats < floor_sats) below++; + size_t below = split.below_floor; static size_t last_below = (size_t)-1; if (below != last_below) { last_below = below; @@ -344,7 +339,7 @@ static int attach_pplns_window(store_t *store, const proxy_config_t *cfg, } LOG_DEBUG("pplns-coinbase: window of %zu miner(s), %.2f difficulty, " - "paying %lld sats", n, total, (long long)payable); + "paying %lld sats", n, total, (long long)split.payable_sats); return 0; } diff --git a/src/pplns.c b/src/pplns.c new file mode 100644 index 0000000..f9026a3 --- /dev/null +++ b/src/pplns.c @@ -0,0 +1,94 @@ +/* The window -> payees split. See pplns.h for why it is its own file. */ + +#include "pplns.h" + +#include +#include + +static void set_err(char *errbuf, size_t errlen, const char *msg) { + if (errbuf && errlen) snprintf(errbuf, errlen, "%s", msg); +} + +int pplns_split_window(int64_t reward_sats, int fee_bps, int have_operator, + const pplns_claim_t *claims, size_t n_claims, + double total_diff, int64_t payout_floor_sats, + coinbase_payee_t *out, size_t cap, + pplns_split_t *res, char *errbuf, size_t errlen) +{ + pplns_split_t r; + memset(&r, 0, sizeof r); + if (res) *res = r; + + if (!claims || !out || n_claims == 0 || n_claims > cap) { + set_err(errbuf, errlen, "no claims to split, or more than will fit"); + return -1; + } + if (!(total_diff > 0.0)) { + set_err(errbuf, errlen, "window has no difficulty to divide by"); + return -1; + } + if (reward_sats <= 0) { + set_err(errbuf, errlen, "the block pays nothing to divide"); + return -1; + } + + /* Same fee rule as every coinbase builder, dust included. Duplicated + * deliberately rather than shared: the builder REFUSES a split that does + * not sum to reward-minus-fee, so this has to compute the identical + * number, and a test that pins them together is worth more than a shared + * helper that hides the coupling. test_pplns.c asserts the agreement. */ + int64_t fee = 0; + if (have_operator && fee_bps > 0) { + int64_t f = (reward_sats * (int64_t)fee_bps) / 10000; + if (f >= COINBASE_DUST_SATS) fee = f; + } + int64_t payable = reward_sats - fee; + if (payable <= 0) { + set_err(errbuf, errlen, "nothing left after the operator fee"); + return -1; + } + + int64_t assigned = 0; + for (size_t i = 0; i < n_claims; ++i) { + if (!(claims[i].difficulty >= 0.0)) { + set_err(errbuf, errlen, "a claim has no difficulty"); + return -1; + } + out[i].address = claims[i].payout_address; + out[i].sats = (int64_t)((double)payable * + (claims[i].difficulty / total_diff)); + if (out[i].sats < 0) { + set_err(errbuf, errlen, "a claim divided to a negative amount"); + return -1; + } + assigned += out[i].sats; + } + + /* The payees have to sum to `payable` EXACTLY or the builder refuses. + * + * Two ways they might not. Truncating division always leaves a few sats + * short, and those go to the largest claim -- claims arrive + * largest-first, so that is out[0]. And a caller that passed a total + * smaller than the claims actually sum to would overshoot, which is not a + * rounding artefact but a broken window: refuse rather than quietly + * paying out more than the block holds. */ + if (assigned > payable) { + set_err(errbuf, errlen, + "claims exceed the window total they were divided by"); + return -1; + } + if (assigned < payable) out[0].sats += payable - assigned; + + /* Predict what the floor will forfeit, using the builder's own clamp so + * the warning cannot disagree with the payment. */ + int64_t floor_sats = payout_floor_sats < COINBASE_DUST_SATS + ? COINBASE_DUST_SATS : payout_floor_sats; + for (size_t i = 0; i < n_claims; ++i) { + if (out[i].sats < floor_sats) r.below_floor++; + } + + r.fee_sats = fee; + r.payable_sats = payable; + if (res) *res = r; + return 0; +} diff --git a/src/pplns.h b/src/pplns.h new file mode 100644 index 0000000..4fe269a --- /dev/null +++ b/src/pplns.h @@ -0,0 +1,68 @@ +#ifndef SIMPLEPOOL_PPLNS_H +#define SIMPLEPOOL_PPLNS_H + +/* Turning a PPLNS window into coinbase payees. + * + * Its own file for the same reason reconcile.c is: this is arithmetic that + * decides what people are paid, and it lived inside a static function in + * main.c where nothing could reach it. A bug here does not crash and does not + * show up in a log -- it pays somebody the wrong amount, or pays them nothing, + * which is the failure mode this whole rail has to be trusted not to have. + * + * Deliberately pure: no store, no template, no config struct, no logging. It + * takes numbers and returns numbers, so a test can state an expected split + * exactly instead of building a chain to find out. main.c keeps the parts + * that genuinely need the world -- querying the window, reading the reward + * out of the template, and saying what happened. */ + +#include +#include + +#include "coinbase.h" + +/* One miner's claim on the window, as the store reports it. */ +typedef struct { + const char *payout_address; + double difficulty; /* this worker's share of the window */ +} pplns_claim_t; + +typedef struct { + int64_t fee_sats; /* the operator's cut, off the top */ + int64_t payable_sats; /* what the payees must sum to, exactly */ + /* How many claims are worth less than payout_floor_sats and will + * therefore be forfeited to the operator by the builder. + * + * Computed here rather than left for the builder to discover because the + * operator has to be told BEFORE a block makes it real -- a count after + * the fact reports a loss, a count now is something they can act on. The + * builder applies the floor itself; this only predicts it, using the same + * clamp so the two cannot disagree. */ + size_t below_floor; +} pplns_split_t; + +/* Divide `reward_sats` across `claims` in proportion to difficulty. + * + * The fee comes off the top exactly as every coinbase builder computes it, + * including the dust rule -- a fee below COINBASE_DUST_SATS is dropped rather + * than emitted as an unrelayable output. The remaining payable amount is + * split by difficulty share, and the payees are then guaranteed to sum to it + * EXACTLY: truncating division leaves a few sats over, and they go to the + * largest claim rather than being dropped, because a coinbase that pays out + * less than it may forfeits the difference to nobody. + * + * `claims` must be ordered largest-difficulty-first, as store_pplns_window() + * returns them, so the remainder lands on the strongest claim. + * + * `total_diff` is the window's total, passed in rather than re-summed: the + * store computes it over rows this array may have been truncated from, and + * silently re-deriving it here would pay a truncated window as if it were + * whole. + * + * Returns 0 on success, negative on error (errbuf populated). */ +int pplns_split_window(int64_t reward_sats, int fee_bps, int have_operator, + const pplns_claim_t *claims, size_t n_claims, + double total_diff, int64_t payout_floor_sats, + coinbase_payee_t *out, size_t cap, + pplns_split_t *res, char *errbuf, size_t errlen); + +#endif /* SIMPLEPOOL_PPLNS_H */ diff --git a/tests/test_pplns.c b/tests/test_pplns.c new file mode 100644 index 0000000..fed8ba3 --- /dev/null +++ b/tests/test_pplns.c @@ -0,0 +1,402 @@ +/* The window -> payees split. + * + * This arithmetic decides what miners are paid, and until it was lifted into + * pplns.c it lived in a static function in main.c where nothing could reach + * it. Nothing here needs a chain: the point of the extraction is that an + * expected split can be stated exactly instead of mined for. + * + * The cases that matter are the MIXED windows -- claims of very different + * sizes, where some clear the payout floor and some do not. Those are exactly + * the windows the regtest harness cannot produce (the regtest window holds + * about two shares, because share difficulty is clamped to network + * difficulty), so this is the only place the forfeit path is exercised + * against numbers somebody chose. + */ + +#include "../src/pplns.h" + +#include +#include +#include +#include + +static int failures = 0; +#define CHECK(cond) do { \ + if (!(cond)) { printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); failures++; } \ +} while (0) + +#define A "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" +#define B "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3" +#define C "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2" +#define D "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy" + +static int64_t sum_payees(const coinbase_payee_t *p, size_t n) { + int64_t t = 0; + for (size_t i = 0; i < n; ++i) t += p[i].sats; + return t; +} + +/* The whole block is spent, always. A coinbase that pays out less than it may + * forfeits the difference to NOBODY -- not to the operator, not to a later + * block, it is simply destroyed -- so the split must account for every + * satoshi or the builder is right to refuse it. */ +static void test_the_split_always_spends_the_whole_block(void) { + const pplns_claim_t claims[] = { + { A, 700.0 }, { B, 200.0 }, { C, 99.0 }, { D, 1.0 }, + }; + coinbase_payee_t out[4]; + pplns_split_t r; + char err[256] = {0}; + CHECK(pplns_split_window(5000000000LL, 100, 1, claims, 4, 1000.0, 546, + out, 4, &r, err, sizeof err) == 0); + CHECK(r.fee_sats == 50000000LL); + CHECK(r.payable_sats == 4950000000LL); + CHECK(sum_payees(out, 4) == r.payable_sats); + CHECK(sum_payees(out, 4) + r.fee_sats == 5000000000LL); + printf("ok: the split spends the block exactly\n"); +} + +/* Proportional to difficulty, and in the order the store hands them over. */ +static void test_each_claim_gets_its_difficulty_share(void) { + const pplns_claim_t claims[] = { { A, 750.0 }, { B, 250.0 } }; + coinbase_payee_t out[2]; + pplns_split_t r; + char err[256] = {0}; + CHECK(pplns_split_window(4000000000LL, 0, 0, claims, 2, 1000.0, 546, + out, 2, &r, err, sizeof err) == 0); + CHECK(r.fee_sats == 0); + CHECK(out[0].sats == 3000000000LL); + CHECK(out[1].sats == 1000000000LL); + CHECK(strcmp(out[0].address, A) == 0); + CHECK(strcmp(out[1].address, B) == 0); + printf("ok: each claim gets its difficulty share\n"); +} + +/* Truncating division always leaves a few sats over. They go to the LARGEST + * claim -- claims arrive largest-first -- rather than being dropped, because + * dropping them would underpay the block and the builder would refuse it. */ +static void test_the_rounding_remainder_goes_to_the_largest_claim(void) { + /* Three equal claims of a reward that does NOT divide by three. + * 100,000,002 does, which is how the first draft of this test passed + * while asserting the wrong thing. */ + const pplns_claim_t claims[] = { { A, 1.0 }, { B, 1.0 }, { C, 1.0 } }; + coinbase_payee_t out[3]; + pplns_split_t r; + char err[256] = {0}; + CHECK(pplns_split_window(100000000LL, 0, 0, claims, 3, 3.0, 546, + out, 3, &r, err, sizeof err) == 0); + CHECK(sum_payees(out, 3) == 100000000LL); + /* 33,333,333 each leaves 1 over, and it goes to the first. */ + CHECK(out[0].sats == 33333334LL); + CHECK(out[1].sats == 33333333LL); + CHECK(out[2].sats == 33333333LL); + printf("ok: the rounding remainder lands on the largest claim\n"); +} + +/* THE MIXED WINDOW. A few big miners and a tail of small ones, which is what + * a real pool looks like and what the regtest harness cannot build. The small + * ones fall under the floor and will be forfeited -- and the split has to say + * so BEFORE a block makes it real, because a count after the fact reports a + * loss while a count now is something an operator can act on. */ +static void test_a_mixed_window_predicts_who_the_floor_will_drop(void) { + /* 3.125 BTC, no fee, so the arithmetic is exact and checkable by hand. + * The tail claims are 1 part in 10 million: 312,500,000 * 1e-7 = 31 sats, + * comfortably under the 546-sat dust floor. */ + const pplns_claim_t claims[] = { + { A, 6000000.0 }, { B, 3999997.0 }, + { C, 2.0 }, { D, 1.0 }, + }; + coinbase_payee_t out[4]; + pplns_split_t r; + char err[256] = {0}; + CHECK(pplns_split_window(312500000LL, 0, 0, claims, 4, 10000000.0, 546, + out, 4, &r, err, sizeof err) == 0); + + /* 60% of 312,500,000 is 187,500,000 on the nose -- but the other three + * claims each truncate DOWN, leaving one satoshi over, and the remainder + * rule puts it on the largest claim. So 187,500,001 is correct and + * 187,500,000 would mean a satoshi had been destroyed. Asserting the + * tidy-looking number here would have been asserting a bug. */ + CHECK(out[0].sats == 187500001LL); + CHECK(out[1].sats == 124999906LL); /* 39.99997%, truncated */ + CHECK(out[2].sats == 62LL); /* 2 parts in 1e7 */ + CHECK(out[3].sats == 31LL); /* 1 part in 1e7 */ + /* Both tail claims are under the floor, and the split says two. */ + CHECK(r.below_floor == 2); + /* And the block is still fully spent -- the forfeit happens in the + * builder, not here; this stage must not quietly drop anyone. */ + CHECK(sum_payees(out, 4) == 312500000LL); + printf("ok: a mixed window predicts the two claims the floor will drop\n"); +} + +/* Raising the floor forfeits more of the window, and the prediction tracks + * it. This is the knob an operator turns to trade coinbase bytes against how + * small a miner they will serve, so it has to mean what it says. */ +static void test_raising_the_floor_drops_more_claims(void) { + const pplns_claim_t claims[] = { + { A, 50.0 }, { B, 30.0 }, { C, 15.0 }, { D, 5.0 }, + }; + coinbase_payee_t out[4]; + pplns_split_t r; + char err[256] = {0}; + /* 100,000,000 sats over 100 difficulty: 50M / 30M / 15M / 5M. */ + struct { int64_t floor; size_t expect; } CASES[] = { + { 546, 0 }, /* everyone clears the dust limit */ + { 6000000, 1 }, /* the 5M claim goes */ + { 20000000, 2 }, /* and the 15M */ + { 40000000, 3 }, /* and the 30M; only the biggest is paid */ + }; + for (size_t i = 0; i < sizeof CASES / sizeof CASES[0]; ++i) { + CHECK(pplns_split_window(100000000LL, 0, 0, claims, 4, 100.0, + CASES[i].floor, out, 4, &r, + err, sizeof err) == 0); + CHECK(r.below_floor == CASES[i].expect); + /* Whatever the floor, the split itself still spends the block. */ + CHECK(sum_payees(out, 4) == 100000000LL); + } + printf("ok: raising the floor drops more claims, and says how many\n"); +} + +/* A floor below the dust limit is clamped UP to it, exactly as the builder + * clamps it. If these two disagreed the pool would warn about one number and + * pay by another, which is worse than not warning at all. */ +static void test_the_floor_prediction_uses_the_builders_clamp(void) { + const pplns_claim_t claims[] = { { A, 999.0 }, { B, 1.0 } }; + coinbase_payee_t out[2]; + pplns_split_t r; + char err[256] = {0}; + /* The small claim comes to 100 sats: under 546, over 1. */ + CHECK(pplns_split_window(100000LL, 0, 0, claims, 2, 1000.0, 1, + out, 2, &r, err, sizeof err) == 0); + CHECK(out[1].sats == 100LL); + CHECK(r.below_floor == 1); /* clamped to 546, not honoured as 1 */ + printf("ok: a sub-dust floor is clamped up, as the builder clamps it\n"); +} + +/* The exact boundaries, which is where a prediction and a payment come apart. + * + * Both rules here are "<" or ">=" in the builder, and a test built only from + * comfortably-inside values cannot tell those from "<=" and ">". That matters + * per miner: a claim worth exactly the floor IS paid, so predicting it as + * forfeited would warn an operator about somebody who is about to be paid -- + * and the reverse would leave a miner unwarned about earning nothing. */ +static void test_the_floor_and_dust_boundaries_are_exact(void) { + coinbase_payee_t out[2]; + pplns_split_t r; + char err[256] = {0}; + + /* A claim worth EXACTLY the floor clears it. 546 parts in 1,000,000 of + * 1,000,000 sats is 546 sats on the nose. */ + const pplns_claim_t at_floor[] = { { A, 999454.0 }, { B, 546.0 } }; + CHECK(pplns_split_window(1000000LL, 0, 0, at_floor, 2, 1000000.0, 546, + out, 2, &r, err, sizeof err) == 0); + CHECK(out[1].sats == 546LL); + CHECK(r.below_floor == 0); /* exactly at the floor is PAID */ + + /* One satoshi under it is not. */ + const pplns_claim_t under[] = { { A, 999455.0 }, { B, 545.0 } }; + CHECK(pplns_split_window(1000000LL, 0, 0, under, 2, 1000000.0, 546, + out, 2, &r, err, sizeof err) == 0); + CHECK(out[1].sats == 545LL); + CHECK(r.below_floor == 1); + + /* The fee's dust boundary, the same way. 1% of 54,600 is 546 exactly, + * which is payable; 1% of 54,500 is 545, which is dust and dropped. */ + const pplns_claim_t one[] = { { A, 1.0 } }; + CHECK(pplns_split_window(54600LL, 100, 1, one, 1, 1.0, 546, + out, 1, &r, err, sizeof err) == 0); + CHECK(r.fee_sats == 546LL); + CHECK(pplns_split_window(54500LL, 100, 1, one, 1, 1.0, 546, + out, 1, &r, err, sizeof err) == 0); + CHECK(r.fee_sats == 0LL); /* 545 is dust: dropped entirely */ + CHECK(out[0].sats == 54500LL); /* and the miner takes all of it */ + + /* And the builder agrees about both, which is the point -- these two + * compute the fee independently and one refuses the other's answer. */ + coinbase_parts_t parts; + char berr[256] = {0}; + coinbase_payee_t whole[] = { { A, 54500LL } }; + CHECK(coinbase_build_window(800000, 54500LL, whole, 1, A, 100, NULL, + NULL, 4, 8, 0, 546, &parts, NULL, + berr, sizeof berr) == 0); + coinbase_parts_free(&parts); + printf("ok: the floor and dust boundaries are exact, and the builder agrees\n"); +} + +/* The fee rule has to match the builders' EXACTLY, dust rule included: they + * refuse a split that does not sum to reward-minus-fee, so a disagreement + * here means no coinbase renders at all. */ +static void test_the_fee_matches_what_the_builder_will_expect(void) { + const pplns_claim_t claims[] = { { A, 1.0 } }; + coinbase_payee_t out[1]; + pplns_split_t r; + char err[256] = {0}; + + CHECK(pplns_split_window(5000000000LL, 100, 1, claims, 1, 1.0, 546, + out, 1, &r, err, sizeof err) == 0); + CHECK(r.fee_sats == 50000000LL); + + /* A fee that would be dust is dropped entirely, not emitted as an + * unrelayable output -- and the miner takes the whole reward. */ + CHECK(pplns_split_window(50000LL, 100, 1, claims, 1, 1.0, 546, + out, 1, &r, err, sizeof err) == 0); + CHECK(r.fee_sats == 0); /* 1% of 50,000 = 500 < 546 */ + CHECK(out[0].sats == 50000LL); + + /* No operator configured: no fee, whatever fee_bps says. */ + CHECK(pplns_split_window(5000000000LL, 100, 0, claims, 1, 1.0, 546, + out, 1, &r, err, sizeof err) == 0); + CHECK(r.fee_sats == 0); + printf("ok: the fee rule matches the builder's, dust included\n"); +} + +/* The split must be ACCEPTABLE TO THE BUILDER, which is the only fee test + * that really counts. + * + * coinbase_build_window() computes the fee itself and refuses a split whose + * payees do not sum to reward-minus-that-fee. So a one-satoshi disagreement + * between these two -- floor vs ceiling division, say -- is not an off-by-one + * in a report, it is a coinbase that never renders: refused on every + * connection, on every job, with the pool quietly serving no work at all. + * + * Asserting the fee against a constant cannot see that, because the constants + * anyone picks by hand tend to divide evenly. This drives the real builder + * with rewards whose fee does NOT, so floor and ceiling differ and only the + * matching rule survives. */ +static void test_the_builder_accepts_what_the_splitter_produces(void) { + /* Rewards chosen so reward*fee_bps/10000 has a remainder. */ + static const int64_t REWARDS[] = { + 5000000001LL, 312500007LL, 1000000003LL, 99999999LL, 654321LL, + }; + static const int FEES[] = { 0, 1, 100, 250, 1000 }; + const pplns_claim_t claims[] = { + { A, 7.0 }, { B, 3.0 }, { C, 1.0 }, + }; + for (size_t i = 0; i < sizeof REWARDS / sizeof REWARDS[0]; ++i) { + for (size_t j = 0; j < sizeof FEES / sizeof FEES[0]; ++j) { + coinbase_payee_t out[3]; + pplns_split_t r; + char err[256] = {0}; + if (pplns_split_window(REWARDS[i], FEES[j], 1, claims, 3, 11.0, + 546, out, 3, &r, err, sizeof err) < 0) { + continue; /* refused for a stated reason; not our case */ + } + /* The real builder, with the real fee rule, on the real split. */ + coinbase_parts_t parts; + char berr[256] = {0}; + int rc = coinbase_build_window(800000, REWARDS[i], out, 3, + A, FEES[j], NULL, "/sp/", 4, 8, + 0, 546, &parts, NULL, + berr, sizeof berr); + if (rc != 0) { + printf("FAIL: reward=%lld fee_bps=%d — the builder refused " + "the splitter's own output: %s\n", + (long long)REWARDS[i], FEES[j], berr); + failures++; + continue; + } + coinbase_parts_free(&parts); + } + } + printf("ok: the builder accepts every split the splitter produces\n"); +} + +/* A total smaller than the claims actually sum to is a broken window, not a + * rounding artefact: dividing by it would pay out MORE than the block holds. + * Refuse rather than hand the builder a split it will reject on every + * connection. */ +static void test_a_window_total_that_is_too_small_is_refused(void) { + const pplns_claim_t claims[] = { { A, 60.0 }, { B, 60.0 } }; + coinbase_payee_t out[2]; + char err[256] = {0}; + CHECK(pplns_split_window(100000000LL, 0, 0, claims, 2, 100.0, + 546, out, 2, NULL, err, sizeof err) < 0); + CHECK(strstr(err, "exceed") != NULL); + printf("ok: a window total smaller than its claims is refused\n"); +} + +static void test_the_degenerate_inputs_are_refused(void) { + const pplns_claim_t claims[] = { { A, 1.0 } }; + coinbase_payee_t out[2]; + char err[256] = {0}; + CHECK(pplns_split_window(1000, 0, 0, NULL, 1, 1.0, 546, out, 2, NULL, err, sizeof err) < 0); + CHECK(pplns_split_window(1000, 0, 0, claims, 0, 1.0, 546, out, 2, NULL, err, sizeof err) < 0); + CHECK(pplns_split_window(1000, 0, 0, claims, 1, 0.0, 546, out, 2, NULL, err, sizeof err) < 0); + CHECK(pplns_split_window(0, 0, 0, claims, 1, 1.0, 546, out, 2, NULL, err, sizeof err) < 0); + /* More claims than the caller's array holds. */ + CHECK(pplns_split_window(1000, 0, 0, claims, 3, 1.0, 546, out, 2, NULL, err, sizeof err) < 0); + printf("ok: degenerate inputs are refused, not divided\n"); +} + +/* Randomised conservation check. + * + * The hand-written cases above pin splits somebody chose; this asserts the + * invariant that has to hold for every split there is, across shapes nobody + * thought to write down. It is the whole block or it is a bug: paid + fee == + * reward, exactly, with no float drift and no negative payee. */ +static void test_conservation_holds_for_random_windows(void) { + srand(20260908); + for (int iter = 0; iter < 20000; ++iter) { + pplns_claim_t claims[16]; + coinbase_payee_t out[16]; + size_t n = 1 + (size_t)(rand() % 16); + double total = 0.0; + for (size_t i = 0; i < n; ++i) { + /* Wildly different magnitudes on purpose: a realistic window has + * a few large miners and a long tail, and that is where a + * proportional split loses satoshis if it is going to. */ + double d = (double)(rand() % 1000000) / (double)(1 + rand() % 1000); + claims[i].payout_address = A; + claims[i].difficulty = d; + total += d; + } + /* Largest-first, as the store returns them. */ + for (size_t i = 0; i + 1 < n; ++i) + for (size_t j = i + 1; j < n; ++j) + if (claims[j].difficulty > claims[i].difficulty) { + pplns_claim_t t = claims[i]; claims[i] = claims[j]; claims[j] = t; + } + if (!(total > 0.0)) continue; + + int64_t reward = 546 + (int64_t)(rand() % 5000000000LL); + int fee_bps = rand() % 1001; + int have_op = rand() % 2; + pplns_split_t r; + char err[256] = {0}; + int rc = pplns_split_window(reward, fee_bps, have_op, claims, n, total, + 546, out, 16, &r, err, sizeof err); + if (rc < 0) continue; /* refusals are a valid answer; see above */ + + int64_t paid = 0; + for (size_t i = 0; i < n; ++i) { + CHECK(out[i].sats >= 0); + paid += out[i].sats; + } + if (paid + r.fee_sats != reward) { + printf("FAIL: iter %d n=%zu reward=%lld paid=%lld fee=%lld\n", + iter, n, (long long)reward, (long long)paid, + (long long)r.fee_sats); + failures++; + return; + } + } + printf("ok: conservation holds across 20000 random windows\n"); +} + +int main(void) { + test_the_split_always_spends_the_whole_block(); + test_each_claim_gets_its_difficulty_share(); + test_the_rounding_remainder_goes_to_the_largest_claim(); + test_a_mixed_window_predicts_who_the_floor_will_drop(); + test_raising_the_floor_drops_more_claims(); + test_the_floor_prediction_uses_the_builders_clamp(); + test_the_fee_matches_what_the_builder_will_expect(); + test_the_floor_and_dust_boundaries_are_exact(); + test_the_builder_accepts_what_the_splitter_produces(); + test_a_window_total_that_is_too_small_is_refused(); + test_the_degenerate_inputs_are_refused(); + test_conservation_holds_for_random_windows(); + if (failures) { printf("test_pplns: %d FAILED\n", failures); return 1; } + printf("test_pplns: all tests passed\n"); + return 0; +} diff --git a/tests/test_pplns.mk b/tests/test_pplns.mk new file mode 100644 index 0000000..ee39300 --- /dev/null +++ b/tests/test_pplns.mk @@ -0,0 +1,3 @@ +build/test_pplns: tests/test_pplns.c src/pplns.c src/coinbase.c src/sha256.c + @mkdir -p build + $(CC) $(CFLAGS) $(LDFLAGS) -o build/test_pplns $^ From 15da40cb5f272cdcf2bfecc38d5e7841b9aa1123 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 8 Sep 2026 21:30:16 +0200 Subject: [PATCH 14/36] cbwin e2e: prove a mixed window forfeits, on chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last uncovered path in this mode. Claims of 100 : 10 : 1 with a floor between the last two: the first two are paid in the block's coinbase, the third gets no output at all, and its satoshis turn up on the operator's. 4459459461 sats -> BIG (100 shares, incl. the rounding remainder) 445945945 sats -> MID (10) 94594594 sats -> operator = 50,000,000 fee + 44,594,594 forfeited — sats -> SMALL (1) — no output 1 claim(s) worth 44594594 sats were forfeited pps_credits rows=0 Why it could not be mined for before: share difficulty is clamped to network difficulty on regtest, so a 2.0x window holds about two shares — every other stage in this file reports "window of 1 miner(s)". A mixed window needs both a much wider multiple and a share history, so this seeds the shares table directly with the pool stopped. That is replaying the pool's own record of accepted work, not stubbing what is under test: the window query, the split, the builder, the block, and the outputs read back off the chain are all real, and the amounts match the arithmetic exactly, remainder included. Two things this cost, both worth recording. The first run failed claiming the pool never warned about the small miner — it had simply not built a second job yet, because the first job of a process carries no window (network difficulty is unread until a template arrives) and the tip watcher rebuilds on a new tip or a 30-second refresh. A 20-second wait read as a missing warning. And the stage is mutation-verified: dropping the floor check in the builder pays SMALL and the suite catches it in the block, which is the only place that mattered. --- README.md | 2 +- tests/test_pplns_coinbase_regtest.sh | 196 +++++++++++++++++++++++++-- 2 files changed, 186 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0fd3f41..5c600eb 100644 --- a/README.md +++ b/README.md @@ -656,7 +656,7 @@ mode, each mining a real chain: | `tests/test_e2e_regtest.sh` | `pps-classic`: the coinbase pays the pool, and shares accrue at the derived rate | | `tests/test_pplns_regtest.sh` | both pooled PPLNS rails distribute a matured block exactly once | | `tests/test_pplns_btc_payout_regtest.sh` | `pplns-btc` pays miners on L1 through the enforcer wallet | -| `tests/test_pplns_coinbase_regtest.sh` | `pplns-coinbase`: the block's coinbase pays the window, the pool holds nothing, and the payout floor is disclosed | +| `tests/test_pplns_coinbase_regtest.sh` | `pplns-coinbase`: the block's coinbase pays the window, the pool holds nothing, the payout floor is disclosed, and a mixed 100 : 10 : 1 window really does forfeit the smallest claim to the operator on chain | | `tests/test_payout_regtest.sh` | the Thunder payout rail settles and confirms | All of them run in CI. For the verification checklist behind each mode, see diff --git a/tests/test_pplns_coinbase_regtest.sh b/tests/test_pplns_coinbase_regtest.sh index 6b44796..a842ef1 100755 --- a/tests/test_pplns_coinbase_regtest.sh +++ b/tests/test_pplns_coinbase_regtest.sh @@ -30,19 +30,24 @@ # forfeited to the operator and never settled, which is a trap unless # the operator can see it — so the disclosure lines are asserted here # exactly like the money is. +# 6. a MIXED window really does forfeit, on chain. Claims of 100 : 10 : 1, +# a floor between the last two: the first two are paid in the coinbase, +# the third gets no output, and its satoshis turn up on the operator's. # -# NOT covered here, deliberately: a forfeit with something actually in it. -# That needs a window holding claims of very different sizes, and this harness -# drives cpuminers against one address. Squeezing the byte budget instead does -# not produce it either: with a single payee, either it fits or the builder -# refuses, no coinbase is rendered and no block is found. +# That last stage is the one this file could not do for a long time, and the +# reason is worth writing down. Share difficulty is clamped to network +# difficulty on regtest, so a 2.0x window holds about two shares -- every +# other stage here reports "window of 1 miner(s)". A mixed window needs a much +# wider multiple AND a share history, so it seeds the shares table directly +# with the pool STOPPED. That is replaying the pool's own record of accepted +# work, not stubbing the thing under test: the window query, the split, the +# builder, the block and the outputs read back off the chain are all real. # -# An earlier version of this file had a stage that squeezed the budget and -# printed how much had carried. It printed 0 every time and passed regardless, -# which is worse than no stage at all. The forfeit arithmetic is covered in -# tests/test_coinbase.c instead, where the amounts can be stated exactly and -# are mutation-verified. What is missing is an end-to-end run with a -# mixed-size window, and it is missing on purpose rather than by oversight. +# An earlier version of this file had a stage that squeezed the byte budget +# and printed how much had carried. It printed 0 every time and passed +# regardless, which is worse than no stage at all. This one asserts the +# amounts: 1 share in 111 of the payable reward, forfeited, and the operator +# holding strictly more than its fee. # # Env: # REGTEST_DIR data dir, WIPED each run (default: /.regtest-cbwin) @@ -371,6 +376,175 @@ BLK_ROWS="$(sqlite3 "$POOL_DB" "SELECT COUNT(*) FROM blocks_found")" echo " blocks_found rows=$BLK_ROWS" [ "$BLK_ROWS" -ge 1 ] || { echo "FAIL: the block was not recorded" >&2; exit 1; } +stage "a MIXED window: some claims paid, the smallest forfeited on chain" +# The one path everything above leaves untouched: a window holding claims of +# very different sizes, where the floor pays some and forfeits the rest, and +# the forfeit is visible in the block. +# +# It cannot be mined for. Share difficulty is clamped to network difficulty on +# regtest, so a 2.0x window holds about two shares -- which is why every stage +# above reports "window of 1 miner(s)". Two levers fix that: a much wider +# window multiple, and a share history seeded before the pool starts. +# +# Seeded, not faked. The shares table is the pool's own record of accepted +# work, and writing it while the pool is STOPPED is replaying history, not +# stubbing the thing under test. Everything downstream is real: the window +# query, the split, the coinbase builder, the block, and the outputs read back +# off the chain. +kill "$POOL_PID" 2>/dev/null || true +wait "$POOL_PID" 2>/dev/null || true +POOL_PID="" + +# Three miners at 100 : 10 : 1, on a fresh ledger so the counts are exactly +# what this stage put there. +MIX_DB="/tmp/simplepool-cbmix.db" +MIX_LOG="/tmp/simplepool-cbmix.log" +MIX_CONF="/tmp/simplepool-cbmix.conf" +rm -f "$MIX_DB" "$MIX_DB-wal" "$MIX_DB-shm" +sqlite3 "$MIX_DB" < "$ROOT/schema.sql" > /dev/null + +BIG="bcrt1qzyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3lgth6c" # 100 shares +MID="bcrt1qyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zs4w3j0" # 10 +SMALL="bcrt1qxvenxvenxvenxvenxvenxvenxvenxvenztev8a" # 1 -> under the floor + +# Each seeded share carries the network difficulty a real one would, so a +# window multiple of N covers N shares. +NETDIFF="$(sqlite3 "$POOL_DB" "SELECT network_difficulty FROM pool_meta WHERE id=1")" +[ -n "$NETDIFF" ] || { echo "FAIL: no network difficulty to size the window" >&2; exit 1; } +echo " seeding 111 shares at difficulty $NETDIFF (100 : 10 : 1)" +{ + echo "BEGIN;" + echo "INSERT INTO workers (id,name,payout_address,first_seen,last_seen) VALUES" + echo " (1,'$BIG','$BIG',1,1),(2,'$MID','$MID',1,1),(3,'$SMALL','$SMALL',1,1);" + for i in $(seq 1 100); do echo "INSERT INTO shares (worker_id,ts,difficulty) VALUES (1,1,$NETDIFF);"; done + for i in $(seq 1 10); do echo "INSERT INTO shares (worker_id,ts,difficulty) VALUES (2,1,$NETDIFF);"; done + echo "INSERT INTO shares (worker_id,ts,difficulty) VALUES (3,1,$NETDIFF);" + echo "COMMIT;" +} | sqlite3 "$MIX_DB" + +# The floor sits between the 1-share claim and the 10-share one. Of a +# 4,950,000,000-sat payable amount: 100/111 = ~4.46e9, 10/111 = ~4.46e8, +# 1/111 = ~4.46e7. A floor of 100,000,000 forfeits exactly the last. +MIX_FLOOR=100000000 +sed -e "s|^db_path = .*|db_path = ${MIX_DB}|" \ + -e "s|^pplns_window_diff_multiple = .*|pplns_window_diff_multiple = 200.0|" \ + -e "s|^pplns_payout_floor_sats = .*|pplns_payout_floor_sats = ${MIX_FLOOR}|" \ + "$POOL_CONF" > "$MIX_CONF" +grep -q "^pplns_payout_floor_sats" "$MIX_CONF" || \ + echo "pplns_payout_floor_sats = ${MIX_FLOOR}" >> "$MIX_CONF" +grep -q "^pplns_window_diff_multiple = 200.0" "$MIX_CONF" || { + echo "FAIL: could not widen the window in the generated config" >&2; exit 1; } + +"$POOL_BIN" "$MIX_CONF" > "$MIX_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 "FAIL: simplepool died on the mixed-window config" >&2 + tail -20 "$MIX_LOG" >&2; exit 1; } + +# The pool must SAY the small miner is about to earn nothing, before a block +# makes it true. That warning is the operator's only chance to act. +# +# Up to 60s, because the FIRST job of a process carries no window -- network +# difficulty is unread until a template arrives -- and the tip watcher only +# rebuilds on a new tip or its 30-second refresh. A 20-second wait looked like +# "the pool never warned" when it simply had not built a second job yet. +for _ in $(seq 1 60); do + grep -q "below the ${MIX_FLOOR}-sat payout floor" "$MIX_LOG" && break + sleep 1 +done +grep -q "below the ${MIX_FLOOR}-sat payout floor" "$MIX_LOG" || { + echo "FAIL: the pool never warned that a miner falls below the floor" >&2 + grep -i "floor" "$MIX_LOG" | tail -5 >&2; exit 1; } +echo " warned: $(grep -o '[0-9]* of [0-9]* miner(s) in the window are below' "$MIX_LOG" | tail -1)" + +MIX_BEFORE=$(cli getblockcount) +node "$ROOT/scripts/regtest/cpuminer.js" --port "$POOL_PORT" --user "$MINER_ADDR" --timeout 180 +MIX_AFTER=$(cli getblockcount) +[ "$MIX_AFTER" -gt "$MIX_BEFORE" ] || { + echo "FAIL: no block was mined on the mixed window" >&2; exit 1; } + +stage "assert the forfeit happened, in the block itself" +MIX_TIP="$(cli getbestblockhash)" +MIX_CB="$(cli getblock "$MIX_TIP" 2 | jq -c '.tx[0]')" +CB_JSON="$MIX_CB" BIG="$BIG" MID="$MID" SMALL="$SMALL" \ +OPERATOR_ADDR="$OPERATOR_ADDR" MIX_FLOOR="$MIX_FLOOR" python3 - <<'PY' +import json, os, sys + +cb = json.loads(os.environ['CB_JSON']) +big = os.environ['BIG'] +mid = os.environ['MID'] +small = os.environ['SMALL'] +op = os.environ['OPERATOR_ADDR'] +floor = int(os.environ['MIX_FLOOR']) + +paid = {} +for o in cb['vout']: + spk = o['scriptPubKey'] + if spk.get('type') == 'nulldata': + continue + paid[spk['address']] = paid.get(spk['address'], 0) + round(o['value'] * 1e8) + +for a, v in sorted(paid.items(), key=lambda kv: -kv[1]): + who = {big: 'BIG (100 shares)', mid: 'MID (10)', small: 'SMALL (1)', + op: 'operator'}.get(a, 'UNKNOWN') + print(f" {v:>14} sats -> {a} ({who})") + +# The two claims above the floor are paid, in the block. +for name, addr in (('BIG', big), ('MID', mid)): + if addr not in paid: + print(f"FAIL: {name} clears the floor but has no coinbase output", + file=sys.stderr) + sys.exit(1) + +# The one below it is NOT -- this is the whole point of the stage. +if small in paid: + print(f"FAIL: SMALL is worth less than the {floor}-sat floor but was paid " + f"{paid[small]} anyway", file=sys.stderr) + sys.exit(1) + +# And its money went to the operator, not nowhere. The operator must hold +# strictly more than the 1% fee, and the block must still be spent whole: +# a forfeit that vanished would show up as a coinbase paying out less than +# it may, which is value destroyed rather than merely redirected. +total = sum(paid.values()) +if total != 5000000000: + print(f"FAIL: the coinbase pays {total}, not the whole 50 BTC block", + file=sys.stderr) + sys.exit(1) +fee_only = 50000000 +op_sats = paid.get(op, 0) +if op_sats <= fee_only: + print(f"FAIL: operator holds {op_sats}, no more than the {fee_only}-sat " + f"fee — the forfeited claim went nowhere", file=sys.stderr) + sys.exit(1) +forfeited = op_sats - fee_only +print(f" forfeited to the operator: {forfeited} sats " + f"(on top of the {fee_only}-sat fee)") +# Roughly 1/111 of the payable amount. Bounded rather than exact because the +# block finder's own share may or may not have entered the window before the +# job was built; either way the SMALL claim is the one that lost. +if not (40000000 <= forfeited <= 50000000): + print(f"FAIL: forfeited {forfeited} sats, expected ~44.6M " + f"(1 share in 111 of the payable amount)", file=sys.stderr) + sys.exit(1) +PY + +# And the pool reported it, with the numbers, so an operator answering "why +# was I not paid?" has something to answer from. +grep -q "were forfeited to the operator" "$MIX_LOG" || { + echo "FAIL: the block paid a forfeit but the pool never reported one" >&2 + grep -i "pplns-coinbase: block" "$MIX_LOG" | tail -3 >&2; exit 1; } +echo " reported: $(grep -o '[0-9]* claim(s) worth [0-9]* sats were forfeited' "$MIX_LOG" | tail -1)" + +# Still no ledger. A forfeit is income, not a debt -- if this mode ever grew a +# carry it would show up here first. +MIX_ROWS="$(sqlite3 "$MIX_DB" "SELECT COUNT(*) FROM pps_credits")" +[ "$MIX_ROWS" = "0" ] || { + echo "FAIL: a forfeit created $MIX_ROWS ledger row(s); it must create none" >&2 + exit 1; } +echo " pps_credits rows=0 — forfeited, not carried" + echo echo "cbwin-e2e: PASS (the window was paid from the block's own coinbase," echo " and the pool never held the reward)" From f78f606b612fa3b138da04162d13a51281123aa7 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 8 Sep 2026 22:05:29 +0200 Subject: [PATCH 15/36] docs: cover all five modes, not the two each doc was written for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven documents discussed pool modes and none of them mentioned pplns-coinbase. Two were actively misleading rather than merely incomplete: - INSTALL.md's "Part F — payout worker (every mode except solo)". Wrong: pplns-coinbase needs no payout worker either. An operator following it would install and monitor a service that finds an empty ledger forever. Retitled, and both no-worker modes are now a row in the rail table with an explicit "skip this whole part". - payout/README.md's pool_mode -> PAYOUT_RAIL table had no row for pplns-coinbase, so someone reading it would hunt for the right rail and find none. Same fix: the mode is in the table, saying the coinbase is the payment. The rest were gaps: - INSTALL.md gained a pplns-coinbase config section — the keys, the two limits, and the forfeit policy stated plainly, since it is the one thing an operator has to decide rather than configure. - VERIFY.md was titled "pps-thunder — verification checklist" and organised by the commits that landed it, predating four of the five modes. It now says so, points at the one end-to-end suite per mode that supersedes a manual pass, and carries a new section 13 for pplns-coinbase: the config refusals, the four disclosure points, the money read off the CHAIN rather than the pool's own database, and the byte budget. - dashboard/README.md documented a card branching on solo/pps-classic. That is the code this branch changed, so the table now covers five modes and records where the "not pps-classic means solo" mislabels were, for whoever adds a sixth. - tests/README.md listed two integration suites; there are six. It also now says plainly that test_integration.sh looks like a solo end-to-end test and is not — it never mines. - OPERATOR_GUIDE.md and CLASSIC_PAYOUTS.md are legitimately scoped to pps-classic, so they say so and point at the modes they do not cover. CLASSIC_PAYOUTS additionally notes that its finding rules out depositing from the coinbase to a SIDECHAIN, and says nothing against paying miners on L1 from the coinbase — which is what solo and pplns-coinbase do. - scripts/regtest/README.md described a stack serving one mode; it serves all five now, one suite each. Every internal link and anchor added here resolves. --- CLASSIC_PAYOUTS.md | 7 +++ INSTALL.md | 51 +++++++++++++++++++-- OPERATOR_GUIDE.md | 18 ++++++++ README.md | 6 ++- VERIFY.md | 93 ++++++++++++++++++++++++++++++++++++++- dashboard/README.md | 51 ++++++++++++++------- payout/README.md | 16 ++++++- scripts/regtest/README.md | 7 +++ tests/README.md | 54 ++++++++++++++++++++++- 9 files changed, 278 insertions(+), 25 deletions(-) diff --git a/CLASSIC_PAYOUTS.md b/CLASSIC_PAYOUTS.md index 8333b07..3b6f095 100644 --- a/CLASSIC_PAYOUTS.md +++ b/CLASSIC_PAYOUTS.md @@ -4,6 +4,13 @@ This is the design behind `pool_mode = pps-classic`, the pool's Thunder-paying PPS mode. It is implemented and running; this doc explains the shape and why it looks the way it does. +> Scoped to `pps-classic`. Four other modes exist — see +> [the five modes](README.md#the-five-modes). The finding below (that the +> enforcer does not credit coinbase outputs as deposits) is what rules out +> depositing straight from the coinbase **on a sidechain**; it says nothing +> about paying miners on L1 from the coinbase, which is exactly what `solo` +> and `pplns-coinbase` do and which works. + ## Why not deposit straight from the coinbase The original design (`pool_mode = pps`, since removed) embedded a BIP300 diff --git a/INSTALL.md b/INSTALL.md index 9a0fade..26348f6 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -527,6 +527,7 @@ pays, and the rail decides what a stratum username is: | --- | --- | | `pplns-thunder` | `[.]` | | `pplns-btc` | `[.]` | +| `pplns-coinbase` | `[.]` | Nothing is credited when a share arrives. A block that reaches **100 confirmations** is split across the shares that produced it, pro rata by @@ -541,6 +542,44 @@ 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. +#### `pplns-coinbase` — the same accounting, no custody + +``` +# ... same as solo, plus: +pool_mode = pplns-coinbase +pplns_window_diff_multiple = 2.0 # optional; this is the default +pplns_payout_floor_sats = 546 # optional; this is the default (dust limit) +coinbase_max_bytes = 1000 # optional; this is the default +# NO pool_btc_address — the config refuses one in this mode +``` + +Everything above about maturity and `pps_credits` stops applying here. The +block's own coinbase pays the whole window directly, one output per miner, so +there is no pool wallet, no payout worker, no ledger row and no 100-block +wait. A reorged block simply never paid, and there is nothing to claw back. +**Skip Part F entirely.** + +Two limits decide how many miners a block can pay, and both cost miners money +rather than the pool: + +- `coinbase_max_bytes` budgets the **whole serialized coinbase**, commitments + included — that is what a rented-hashrate marketplace measures when it + refuses a job as oversized. On a drivechain the BIP300/301 `OP_RETURN`s + spend it before any payout does. +- `pplns_payout_floor_sats` is the least a claim must be worth to get an + output at all. + +**A claim that clears neither is forfeited to the operator — not carried, not +recorded, not settled later.** That is deliberate: there is nowhere to hold it +because the payment *is* the block. The consequence is a hashrate floor — +a miner too small to clear it will mine here, submit valid shares and earn +nothing indefinitely. The proxy states the floor at startup, warns per +template how many miners fall below it, reports per block what was forfeited, +and publishes the number so the dashboard states it to miners before they +connect. **Publish it on your pool page as well.** See +[the five modes](README.md#the-five-modes) and +[`VERIFY.md` section 13](VERIFY.md). + ### Optional: Redis broadcast Add to any mode's `proxy.conf`: @@ -618,7 +657,7 @@ on every request. --- -## Part F — payout worker (every mode except solo) +## Part F — payout worker (only the modes that pool the reward) The payout worker drains `pps_credits.accrued_sats - paid_sats`. One worker, two rails, selected by `PAYOUT_RAIL`: @@ -628,9 +667,15 @@ worker, two rails, selected by `PAYOUT_RAIL`: | `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 | +| `solo`, `pplns-coinbase` | — | **do not install this worker**: the coinbase is the payment | + +**Skip this whole part on `solo` and `pplns-coinbase`.** Neither writes a +`pps_credits` row, so there is nothing to drain — the worker would run, +find an empty ledger and pay nobody. Harmless, but it is a service to +monitor, alert on and misdiagnose for no reason. -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: +For the other three, 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 diff --git a/OPERATOR_GUIDE.md b/OPERATOR_GUIDE.md index d13d9ed..1e1eba4 100644 --- a/OPERATOR_GUIDE.md +++ b/OPERATOR_GUIDE.md @@ -4,6 +4,24 @@ Everything you need to run this pool day-to-day. Assumes the branch already deployed (see `CLASSIC_PAYOUTS.md` for background on why the design looks like this). +> **This guide is specific to `pool_mode = pps-classic`.** The pool ships five +> modes and they differ in what a stratum username is, whether a payout worker +> exists at all, and who holds the money in between — so the operational +> advice below does not transfer wholesale. See +> [the five modes](README.md#the-five-modes) for what each one is, and in +> particular: +> +> - `solo` and `pplns-coinbase` have **no payout worker and no pool wallet**; +> the coinbase is the payment. Everything here about Thunder deposits, the +> reserve, and `simplepool-payout.service` simply does not apply. +> - `pplns-thunder` and `pplns-btc` reuse this guide's payout worker, but pay +> on maturity out of a block actually found rather than a reserve, so there +> is no reserve to size or top up. +> - `pplns-coinbase` additionally has a **payout floor**: a claim worth less +> than `pplns_payout_floor_sats` is forfeited to the operator and never +> settled. That is a policy you have to publish to your miners, not just a +> setting. [`VERIFY.md` section 13](VERIFY.md) is its operational checklist. + --- ## Quick reference diff --git a/README.md b/README.md index 5c600eb..d67dd84 100644 --- a/README.md +++ b/README.md @@ -132,8 +132,10 @@ and what a stratum username is: 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: + These two differ only in the rail the balance is finally paid over, and + that choice is what a stratum username has to be. (The third PPLNS mode, + `pplns-coinbase` below, has no balance and no rail at all — it pays out of + the block itself.) - **`pplns-thunder`** pays over Thunder, like `pps-classic`, and reuses the same payout worker draining the same `pps_credits` table. Username diff --git a/VERIFY.md b/VERIFY.md index efe4ba0..6c3fe33 100644 --- a/VERIFY.md +++ b/VERIFY.md @@ -1,4 +1,4 @@ -# `pps-thunder` — verification checklist +# Verification checklist Step-by-step checks to confirm each landed piece behaves as advertised. Tick boxes as you go; each section is independent and you can skip @@ -8,6 +8,25 @@ investigate. If a step fails, the section header points at the commit that owns the behaviour, so `git show ` is a quick way to inspect. +**Sections 0–12 were written for the original `pps-thunder` work and are +organised by the commits that landed it.** They still hold, but they predate +four of the five pool modes. What covers the modes now is one end-to-end +regtest suite each, all of them in CI, all of them mining a real chain: + +| Mode | Suite | +| --- | --- | +| `solo` | `tests/test_solo_regtest.sh` | +| `pps-classic` | `tests/test_e2e_regtest.sh` | +| `pplns-thunder`, `pplns-btc` | `tests/test_pplns_regtest.sh` | +| `pplns-btc` payouts | `tests/test_pplns_btc_payout_regtest.sh` | +| `pplns-coinbase` | `tests/test_pplns_coinbase_regtest.sh` | +| Thunder payout rail | `tests/test_payout_regtest.sh` | + +Running `bash tests/.sh` is a stronger check than any manual section +below, because it asserts against the chain rather than against a log. Section +13 is the manual pass for `pplns-coinbase`, which is the mode with a policy an +operator has to decide on rather than merely configure. + --- ## 0 · Prerequisites (one-time) @@ -459,7 +478,77 @@ drove the operator-triggered deposit design in --- -## 13 · Teardown +## 13 · Coinbase-direct PPLNS (`pplns-coinbase`) + +Owns: the coinbase-direct rail. The automated version of all of this is +`bash tests/test_pplns_coinbase_regtest.sh`; do that first. This section is +the manual pass, and it exists because this mode has a **policy** an operator +has to agree with, not just a config to fill in. + +### 13.1 · The config refuses what the mode cannot do + +- [ ] `pool_btc_address` set alongside `pool_mode = pplns-coinbase` is + refused at startup: *"'pool_btc_address' must not be set when + pool_mode=pplns-coinbase"*. There is no pool wallet in this mode. +- [ ] `pplns_payout_floor_sats = -1` is refused (*"must be >= 0"*). +- [ ] `coinbase_max_bytes = 150` is refused (*"too small to hold a coinbase + and a single payout"*). +- [ ] No payout worker is installed. `solo` and `pplns-coinbase` need none — + see [`payout/README.md`](payout/README.md). + +### 13.2 · The floor is disclosed, four ways + +This is the whole justification for forfeiting rather than carrying, so check +it rather than assume it. + +- [ ] **Startup**, beside the identity line: *"payout floor N sats — a miner + whose share of a block is worth less than that is NOT PAID…"*. It prints + even when the node is unreachable, because it is a config fact. +- [ ] **Per template**, when someone in the window is below it: *"N of M + miner(s) in the window are below the …-sat payout floor and will earn + NOTHING from the next block"*. Only re-logged when the count changes. +- [ ] **Per block**: either *"paid all N miner(s)"* or *"N claim(s) worth X + sats were forfeited to the operator"*. +- [ ] **The dashboard**, before anyone connects. Open `/` and read the + "About the numbers" card: it must state the floor in sats and say the + amount is *not carried forward and not paid later*. If it does not, the + proxy is on a build that predates `pool_meta.pplns_payout_floor_sats` — + the card stays silent rather than inventing a default, so check + `sqlite3 shares.db "SELECT pplns_payout_floor_sats FROM pool_meta"`. +- [ ] You have published the floor on your pool page. Nothing in the software + can do this one for you. + +### 13.3 · The money, read off the chain + +Not out of the pool's own database — that is the pool marking its own +homework. `bitcoin-cli getblock 2 | jq '.tx[0].vout'`: + +- [ ] One output per miner in the window, plus the operator's. +- [ ] **No output pays an address the pool controls** beyond the operator fee. + There is no pool wallet, so a third address means something is wrong. +- [ ] The outputs sum to the whole block reward. A coinbase paying out less + than it may destroys the difference. +- [ ] The operator output is `fee + forfeits`, so it is **larger than + `fee_bps` alone** on any block that forfeited. That is the forfeit + arriving, and it is the one number that proves it went somewhere rather + than nowhere. +- [ ] `sqlite3 shares.db "SELECT COUNT(*) FROM pps_credits"` is **0**. This + mode writes no ledger row, ever. Any row means a pooled mode's accrual + path ran. + +### 13.4 · The byte budget + +- [ ] Measure a real coinbase: `bitcoin-cli getblock 2 | + jq -r '.tx[0].hex' | wc -c` ÷ 2 = bytes. Compare against + `coinbase_max_bytes`. +- [ ] On a drivechain, note the BIP300/301 `OP_RETURN` count. They spend the + same budget the payouts do, so the number of miners a block can pay + moves with sidechain activity. Reported in production: the same 16 + payouts cost 817 bytes against four commitments and 769 against three. + +--- + +## 14 · Teardown ``` scripts/regtest/stop.sh diff --git a/dashboard/README.md b/dashboard/README.md index 9fbe7d1..de2292f 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -76,11 +76,11 @@ report that a check is failing was still produced successfully. Watch ## Pool identity Every page carries a strip under the header naming what this pool actually -is: the **network** its coinbases are built for, the **mode** (`solo` or -`pps-classic`) and fee, the **coinbase tag**, the **operator address** the -fee is paid to, and — under `pps-classic` — the **pool wallet** the -net-of-fee reward goes to. `/api/status` returns the same five fields under -`pool`. +is: the **network** its coinbases are built for, the **mode** (one of `solo`, +`pps-classic`, `pplns-thunder`, `pplns-btc` or `pplns-coinbase`) and fee, the +**coinbase tag**, the **operator address** the fee is paid to, and — in the +modes that pool the reward — the **pool wallet** the net-of-fee reward goes +to. `/api/status` returns the same fields under `pool`. None of it is derivable from the stratum URL a miner was handed. The port looks identical whether the pool is mining mainnet or regtest, whether a @@ -110,17 +110,36 @@ question. The explanatory card on `/` branches on `pool_mode`, because almost nothing in it is shared between the modes: -| | `solo` | `pps-classic` | -| --- | --- | --- | -| A share that isn't a block | worth nothing | credited at the live rate | -| Block reward goes to | the finder, in the coinbase | the pool's BTC wallet | -| Stratum username | a **Bitcoin** address (P2WPKH / P2PKH / P2SH — **not** taproot) | a **Thunder** address | -| Rejection if you get it wrong | `invalid payout address in stratum username` | `invalid thunder address` | - -That last row is why this is not cosmetic. `src/stratum.c` branches on -`pps_enabled` at authorize, so the card's instructions are load-bearing: a -solo pool that tells miners to use a Thunder address is telling them to do -the one thing that cannot work. +| | `solo` | `pps-classic` | `pplns-thunder` | `pplns-btc` | `pplns-coinbase` | +| --- | --- | --- | --- | --- | --- | +| A share that isn't a block | worth nothing | credited at the live rate | a claim on the next block found | a claim on the next block found | a claim on the next block found | +| Block reward goes to | the finder, in the coinbase | the pool's BTC wallet | the pool's BTC wallet | the pool's BTC wallet | **the whole window, in the coinbase** | +| A balance moves | never | as each share arrives | on maturity, over Thunder | on maturity, on L1 | never — the block is the payment | +| Stratum username | a **Bitcoin** address (P2WPKH / P2PKH / P2SH — **not** taproot) | a **Thunder** address | a **Thunder** address | a **Bitcoin** address | a **Bitcoin** address | +| Rejection if you get it wrong | `invalid payout address in stratum username` | `invalid thunder address` | `invalid thunder address` | `invalid payout address in stratum username` | `invalid payout address in stratum username` | + +The username row is why this is not cosmetic. `src/stratum.c` branches at +authorize, so the card's instructions are load-bearing: a pool that tells +miners to use a Thunder address when it wants a Bitcoin one is telling them to +do the one thing that cannot work. + +**`pplns-coinbase` gets one more thing the others do not: the payout floor.** +That mode does not pay a claim worth less than `pplns_payout_floor_sats` — it +forfeits it to the operator, permanently, with no ledger entry and no later +settlement. The card states the number before anyone connects, because the +operator's log is the one place the miner it costs cannot look. It renders +only when the proxy published a floor (`pool_meta.pplns_payout_floor_sats`); +an older proxy stores NULL, and printing a default there would be stating some +other operator's policy for them. + +A note on what this card used to do: it branched on `solo` / `pps-classic` +only, so all three PPLNS modes fell through to *"this pool has not published +its mode yet"* — directly beneath an identity strip that named the mode +correctly — followed by connection guidance for two modes, neither of which +was theirs. Three other places answered "not `pps-classic`" with the word +*solo*: the worker page's **Owed** field, the templates page's PPS rate, and +the `pps_difficulty` health check. If you add a sixth mode, those are the +places to check. Every figure comes from `pool_meta` — rate, gross, fee, operator address, pool wallet, network — and the address examples follow the pool's network, so diff --git a/payout/README.md b/payout/README.md index 2927ebd..f75b236 100644 --- a/payout/README.md +++ b/payout/README.md @@ -10,7 +10,7 @@ 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 +## Two rails, and two modes that need none 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 @@ -21,6 +21,20 @@ username even is. | --- | --- | --- | --- | | `pps-classic`, `pplns-thunder` | `thunder` (default) | Thunder address | Thunder `create_transfer` | | `pplns-btc` | `btc` | Bitcoin address | enforcer `WalletService/SendTransaction` | +| `solo`, `pplns-coinbase` | **do not run this worker** | Bitcoin address | the block's own coinbase | + +> **Two modes need no payout worker at all.** In `solo` and `pplns-coinbase` +> the coinbase *is* the payment — the pool never receives the reward, holds no +> wallet and writes no `pps_credits` row, so there is nothing for this worker +> to drain. Running it against one of those pools is harmless (it finds an +> empty ledger and pays nobody) but it is a service to monitor, alert on and +> misdiagnose for no reason. Do not install it. +> +> If you are looking for where a `pplns-coinbase` miner gets paid: in the +> block, at the moment it is found, one coinbase output per miner. See the +> mode's section in [../README.md](../README.md#the-five-modes) — including +> the payout floor, below which a claim is forfeited to the operator rather +> than accrued here. Everything that makes a payout safe is written once and shared: the write-ahead `payouts_in_flight` row, one transaction per batch, and crediting diff --git a/scripts/regtest/README.md b/scripts/regtest/README.md index 24871a3..41f28e1 100644 --- a/scripts/regtest/README.md +++ b/scripts/regtest/README.md @@ -4,6 +4,13 @@ Local stack for validating simplepool's coinbase shape against the canonical LayerTwo-Labs enforcer, and for reproducing the finding that killed the coinbase-as-deposit design (see below). +The diagram shows `pps-classic` because that is what this stack was first +built for, but every pool mode is now driven against it by its own one-shot +suite in `tests/` — `solo`, `pps-classic`, both custodial PPLNS rails, and +`pplns-coinbase`. Each allocates its own ports and wipes its own chain dir, +so they neither collide with each other nor with a dev stack started here. +See [`tests/README.md`](../../tests/README.md). + ## Stack ``` diff --git a/tests/README.md b/tests/README.md index 9789484..3633e55 100644 --- a/tests/README.md +++ b/tests/README.md @@ -6,6 +6,20 @@ one `.mk` fragment each). No external processes needed. CI runs these in `.github/workflows/check_build.yaml`. +Two of them exist because the logic they cover was once unreachable from any +test, sitting in a `static` function inside `main.c`: + +- `test_reconcile.c` — the block-confirmation pass (`src/reconcile.c`). +- `test_pplns.c` — the window-to-payees split (`src/pplns.c`): the fee, the + proportional division, the rounding remainder and the payout-floor + prediction. It is also the only place the **mixed-window** cases can be + stated exactly, because on regtest the window holds about two shares (share + difficulty is clamped to network difficulty), so a chain cannot produce a + window of wildly different claim sizes without help. + +`make asan` runs a subset under AddressSanitizer + UBSan; `make coverage` +reports line and function coverage of the unit suites only. + ## Integration tests Upstream binary versions are pinned in the pinned-versions block of @@ -50,9 +64,47 @@ the version last validated against. bash tests/test_payout_regtest.sh -Both one-shot tests allocate their stack ports dynamically per run, so +- `test_solo_regtest.sh` — `pool_mode=solo`, the default and the mode most + operators run. Two miners with two **different** addresses mine a block + each, and each block's coinbase must pay its own finder: that is the + defining property of solo, and a regression rendering one coinbase for + every connection would still pass a single-miner test. Also asserts the + enforcer's commitments survived, that nothing was credited off-chain, and + that the pool reported `mode=solo` — which pins the default, since the + config sets no `pool_mode` at all. Own `.regtest-solo/` dir. + +- `test_pplns_regtest.sh` — both custodial PPLNS rails, and both + confirmation paths. Mines to maturity and asserts a matured block is + distributed across its window exactly once. Own `.regtest-pplns/` dir. + +- `test_pplns_btc_payout_regtest.sh` — the L1 payout rail: three miners, one + batched transaction through the enforcer's wallet, with a shared address + summed. Own `.regtest-btcpay/` dir. + +- `test_pplns_coinbase_regtest.sh` — `pool_mode=pplns-coinbase`, which has no + ledger step at all: the payment IS the block. Asserts the coinbase pays the + window on chain, that no output pays anything the pool controls beyond its + fee, that `pps_credits` stays empty, and that the payout floor is disclosed + at startup, per template and per block. + + Its last stage is the one worth knowing about. A **mixed** window — claims + of 100 : 10 : 1 with the floor between the last two — cannot be mined for + on regtest, because a 2.0x window holds about two shares. So that stage + widens the window multiple and seeds the shares table directly, with the + pool stopped. That is replaying the pool's own record of accepted work, not + stubbing what is under test: the window query, the split, the builder, the + block and the outputs read back off the chain are all real, and the + forfeited amount is asserted against the arithmetic. Own `.regtest-cbwin/` + dir. + +Every one-shot test allocates its stack ports dynamically per run, so they can run concurrently — with each other and with a dev stack from `scripts/regtest/start.sh` (which keeps the traditional fixed ports; override via the `REGTEST_*_PORT` env vars). CI runs them as separate jobs in `.github/workflows/integration_tests.yaml` on every PR and push to main. + +One caveat about `test_integration.sh`, first in the list above: it looks +like a solo end-to-end test and is not. It never mines, so it cannot see +whether a coinbase pays the right person, and it is not in CI. That is what +`test_solo_regtest.sh` was written for. From 91b88d7ec37261e3270c717c08e51863479a8499 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 8 Sep 2026 22:19:50 +0200 Subject: [PATCH 16/36] docs: sequence diagrams for every mode and the payout protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTML explainer had deep sections for solo and pps-classic only, so three of the five modes were named in the overview and never explained. It also still described the payout worker as pps-classic's, and its stack diagram labelled that worker "pps-classic only". New section "How each mode pays, step by step": five inline-SVG sequence diagrams, one per mode plus the payout protocol, drawn in the page's existing idiom — hand-written SVG, no library, colours from the page's own CSS variables so they follow the reader's theme. Verified rendered in both light and dark, not just in the markup. What the diagrams are for is the thing prose kept burying: WHEN a miner's work becomes money, and whether the pool ever holds it. Solo and pplns-coinbase have no step after the block — the coinbase is the payment. pps-classic credits before it has earned anything, which is what the reserve funds. The two custodial rails credit on maturity. Drawn side by side that is one glance rather than four sections. Also corrected, all of it stale rather than merely thin: - "Payouts over Thunder" is now "Payouts, and the modes that need none", opening with which three modes run a worker and which two must not. - the stack diagram's "pps-classic only" label on the payout worker, and the prose under it claiming only solo can skip the right-hand half. - the config table said pool_btc_address was "pps-classic only"; it is required by three modes and REFUSED by pplns-coinbase, where setting it is a config error rather than a no-op. - the accrual-gate note said the gate skips solo; it skips everything except pps-classic, because nothing else prices a share on arrival. Every internal anchor still resolves. --- docs/simplepool.html | 281 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 269 insertions(+), 12 deletions(-) diff --git a/docs/simplepool.html b/docs/simplepool.html index d6c422b..b5f75af 100644 --- a/docs/simplepool.html +++ b/docs/simplepool.html @@ -302,8 +302,9 @@

simplepool

  • Difficulty & vardiff
  • Solo mode
  • pps-classic mode
  • +
  • How each mode pays
  • Where the fee lands
  • -
  • Payouts over Thunder
  • +
  • Payouts
  • Auditing every number
  • The data model
  • Connect a miner
  • @@ -603,9 +604,13 @@

    pool_mode = pplns-coinbase

    The stack

    - In solo mode everything to the right of bitcoind is optional. - In pps-classic the enforcer and a Thunder node join the picture, because - that is where miners actually get paid. + How much of this you run depends on the mode. In solo and + pplns-coinbase everything to the right of bitcoind + is optional — the coinbase is the payment, so there is no worker and no + wallet. pps-classic and pplns-thunder add the + payout worker and a Thunder node, because that is where miners actually get + paid; pplns-btc adds the worker but pays on L1 through the + enforcer's wallet instead, so no Thunder node appears at all.

    @@ -667,7 +672,7 @@

    The stack

    payout worker - pps-classic only + 3 of 5 modes Thunder node @@ -1383,9 +1388,14 @@

    When fair value stops being fair

    are working for nothing.

    - The gate applies to pps-classic only. Solo has no - accrual to suspend — it pays each miner from their own coinbase — so the - gate never touches it, whatever the chain's difficulty is doing. + The gate applies to pps-classic only, because it is the + only mode that prices a share on arrival. Nothing else has an accrual to + suspend: solo and pplns-coinbase pay out of the + coinbase itself, and the two custodial PPLNS rails value a share in + hindsight out of a block actually found — so a chain whose difficulty has + collapsed simply produces smaller claims rather than promises the pool + cannot keep. The gate never touches any of them, whatever the chain's + difficulty is doing.

    @@ -1408,6 +1418,230 @@

    Username

    + +
    +

    How each mode pays, step by step

    +

    + The same five modes as above, drawn as sequences rather than described. + What changes between them is when a miner's work turns into + money — and, in two of them, whether the pool ever holds that + money at all. +

    + +

    solo — paid in the block you found

    +

    + The coinbase is rendered per connection, so every miner is + served a job whose coinbase pays that miner. Finding a block and being paid + are the same event; there is nothing after it. +

    +
    +
    + + solo mode: the finder is paid in the block it found + A miner subscribes, simplepool builds a coinbase paying that miner and hands out work. When the miner finds a block the coinbase already pays it, so no ledger and no payout step exist. + + + + + + + MinerASICsimplepool:3334bitcoind+ enforcerBitcoin L1the chain + + authorize <bitcoin-address>getblocktemplatetemplatebuild coinbase paying THIS minernotify (cb1 / cb2)submit (a block!)submitblockblock acceptedThe miner is already paid — the coinbase is the payment. No ledger, no payout worker, no wait. + +
    +
    + +

    pps-classic — paid on arrival, out of a reserve

    +

    + A share is priced the moment it is accepted, whether or not it ever becomes + a block. That is the appeal and the cost: the pool owes money before it has + earned any, and the gap has to be funded by an operator reserve measured in + block rewards. +

    +
    +
    + + pps-classic: every share is priced on arrival, the pool carries the risk + Each accepted share is credited immediately at a rate derived from the template. The coinbase pays the pool wallet, and a payout worker settles balances over Thunder on a daily batch. + + + + + + + MinerASICsimplepool:3334shares.dbSQLitepayout workersystemdThundersidechain #9 + + authorize <thunder-address>submit (an ordinary share)price it: rate x difficultycredit NOW, block or notThe pool owes this miner before it has earned anything. That gap is the operator reserve.submit (a block)The coinbase pays the POOL wallet, not the miner.daily: who is owed?one batched transfercredit paid_sats on CONFIRMATION + +
    +
    + +

    pplns-thunder / pplns-btc — paid when a block matures

    +

    + Nothing is promised in advance. A share is a claim on blocks this pool + actually finds, so the pool never owes more than it has just been paid and + there is no reserve to size. The two rails differ only in where the balance + is finally settled, which is what a stratum username has to be. +

    +
    +
    + + pplns-thunder and pplns-btc: a matured block is split across the work that found it + Shares are recorded but not priced. When a block reaches 100 confirmations it is divided across the window of shares that produced it, and the payout worker settles the resulting balances over Thunder or on Bitcoin L1. + + + + + + + MinerASICsimplepool:3334shares.dbSQLitepayout workersystemd + + submit (an ordinary share)record it — credited 0Nothing is promised. The pool never owes more than it has just been paid.submit (a block)row: hash + window size...100 confirmations later, on a new tip...reconcile: still in the chain?split the block across the windowdaily: who is owed?pay: Thunder, or L1 via the enforcerpaid_sats on confirmation + +
    +
    + +

    pplns-coinbase — the same accounting, no custody

    +

    + The window is snapshotted onto the job when the template is built, so the + coinbase carries one output per miner in it. No pool wallet, no ledger row, + no maturity wait — and no way to hold a claim too small to be worth an + output, which is why this mode has a payout floor and forfeits what falls + below it. See the five modes for the policy in full. +

    +
    +
    + + pplns-coinbase: the block pays the whole window, directly + The window is snapshotted onto the job when the template is built, so the coinbase carries one output per miner. There is no pool wallet, no ledger and no maturity wait — but a claim below the payout floor is forfeited to the operator. + + + + + + + MinerASICsimplepool:3334shares.dbSQLiteBitcoin L1the chain + + who is in the window NOW?claims, largest firstsplit by difficulty; drop anything under the floorA claim below pplns_payout_floor_sats is FORFEITED to the operator — not carried, not settled later.notify — pays the whole windowsubmit (a block)submitblockEveryone above the floor is paid, in this block. No ledger row is ever written. + +
    +
    + +

    The payout worker, in the three modes that have one

    +

    + solo and pplns-coinbase never run this. For the + other three it is the same worker and the same protocol whichever rail it + is driving; only the client at the end differs. The ordering is the whole + design: the write-ahead row goes in before the money moves, and + paid_sats is credited only once the transaction confirms. +

    +
    +
    + + the payout worker: never pay twice, never claim to have paid + A write-ahead in-flight row is written before the transaction is sent, so a crash mid-payout is recoverable; paid_sats is credited only once the transaction confirms. + + + + + + + payout workersystemdshares.dbSQLitethe railThunder / L1 + + who clears PAYOUT_MIN_SATS?write in-flight row FIRSTWritten before the money moves. A crash here is recoverable; the reverse order is not.ONE transaction for the whole batchtxidstore txid against the in-flight row...later ticks, until it confirms...is the txid confirmed yet?NOW credit paid_sats, clear in-flightCrediting on confirmation, not on send, is what makes a lost transaction a retry rather than atheft. + +
    +
    + +
    + Why the order matters more than the rail +

    + Crediting on send rather than on confirmation would + make a dropped transaction indistinguishable from a completed one: the + ledger would say paid, the chain would say nothing, and the miner would + be out of pocket with no way to demonstrate it. Writing the in-flight row + first costs one extra round trip and turns every crash into a retry + rather than a loss. +

    +
    +
    +

    Where the fee lands

    @@ -1462,12 +1696,32 @@

    In pps-classic

    -

    Payouts over Thunder

    +

    Payouts, and the modes that need none

    - pps-classic only. The design goal is narrow and unglamorous: - never pay twice, and never claim to have paid when you haven't. + The design goal is narrow and unglamorous: never pay twice, and + never claim to have paid when you haven't.

    +
    + Which modes this applies to +

    + Three of the five. pps-classic and + pplns-thunder settle over Thunder, described below; + pplns-btc runs the same worker and the same protocol with + PAYOUT_RAIL=btc, paying on Bitcoin L1 through the enforcer's + own wallet rather than a sidechain. +

    +

    + solo and pplns-coinbase have no payout + worker at all — in both, the coinbase is the payment, + so there is no balance to hold and nothing to drain. Do not install it + for those: it would run, find an empty ledger, pay nobody, and give you a + service to monitor and misdiagnose for no reason. The + sequence diagrams above show why the step does + not exist there. +

    +
    +

    Once a day, in one transaction

    Payouts run as a daily batch. Once every 24 hours, everyone @@ -1935,7 +2189,10 @@

    Configuration

    fee_bps100 Fee in basis points; 100 = 1%, hard cap 1000 = 10%. 0 drops the fee output entirely. pool_btc_address— - pps-classic only, and required there: the coinbase pays here. + Required by pps-classic, pplns-thunder and + pplns-btc: the coinbase pays here. Unused in solo, + and refused in pplns-coinbase, which has no + pool wallet at all — setting it there is a config error, not a no-op. pps_sats_per_diffunset Leave it unset. See the warning in section 8. listen_addr / listen_port0.0.0.0 / 3334 From d6a42995dfd69c1f62ffc5744d115614cca575f4 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 8 Sep 2026 22:33:24 +0200 Subject: [PATCH 17/36] pplns.h: the total_diff comment contradicted store.h and the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit store.h and store.c both say out_total_diff covers exactly the rows returned — when the window is truncated the tail leaves the total as well as the entries. pplns.h said the total still counted the truncated rows. The code is the safe version, so nothing is mispaid today. The comment is still worth fixing before it becomes true: acting on it would make the denominator larger than the claims sum, every payee would be shorted, and the remainder rule would land the entire shortfall on out[0] — the largest miner silently absorbing everyone else's share. The assigned > payable guard catches only the opposite error. Caught by Wired4ncer reviewing #76. --- src/pplns.h | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/pplns.h b/src/pplns.h index 4fe269a..afb988e 100644 --- a/src/pplns.h +++ b/src/pplns.h @@ -53,10 +53,19 @@ typedef struct { * `claims` must be ordered largest-difficulty-first, as store_pplns_window() * returns them, so the remainder lands on the strongest claim. * - * `total_diff` is the window's total, passed in rather than re-summed: the - * store computes it over rows this array may have been truncated from, and - * silently re-deriving it here would pay a truncated window as if it were - * whole. + * `total_diff` is the window's total as the store reported it, passed in + * rather than re-summed here. + * + * It covers EXACTLY the claims in `claims`, truncation included: when + * store_pplns_window() cannot fit the whole window it drops the tail from the + * total as well as from the entries, so the survivors divide the block between + * them rather than funding an output that is never created. This comment used + * to say the opposite -- that the total still counted truncated rows -- which + * store.h and store.c both contradict (LayerTwo-Labs/simplepool#76). Believing + * the old version would make a denominator larger than the claims sum, every + * payee would be shorted, and the whole shortfall would land on out[0] via the + * remainder rule: the largest miner silently absorbing everyone else's. The + * `assigned > payable` check below catches the opposite error only. * * Returns 0 on success, negative on error (errbuf populated). */ int pplns_split_window(int64_t reward_sats, int fee_bps, int have_operator, From a5142135b817f7c2d41a61b7a59b601d07b305a6 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 8 Sep 2026 22:56:36 +0200 Subject: [PATCH 18/36] pplns-coinbase: pay dropped claims to the other miners, and stop rescanning every share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from Wired4ncer's review of #76, both verified on this branch. ## The operator was taking a quarter of the block A claim below the payout floor or past the byte budget had its sats added to the operator's output. The rule was defended as a dust policy. It was not one. Measured here: 100 miners on a 1/n hashrate spread, default 1000-byte budget. 28 paid, 72 cut by the byte cap, NONE by the dust floor — and the operator received 25.05% of the block on a 1% advertised fee. Two things made that indefensible rather than merely harsh. A miner's window share tracks its hashrate, so the same miners fall below the cut every block: the rule paid them nothing ever, not occasionally. And the operator's take rose as the coinbase shrank — 46% of the block at a 400-byte budget against 2% at 3000 — so starving your own miners was the revenue-maximising move. Dropped claims are now redistributed across the miners the coinbase COULD pay. Every property the forfeit had is kept: the block still pays out to the satoshi, the pool still holds nothing, no ledger appears. What changes is only who receives what there was no room for — the other miners, not the house. The operator's take is now 1.00% at every budget from 400 to 3000 bytes, and a test pins that it cannot be moved by tightening the coinbase. A pool with no operator_address can now also run this mode, since nothing but the fee lands there any more. ## store_pplns_window() re-read the entire shares table, every template The running SUM() was computed over all of `shares` and the window boundary applied afterwards, so there was no early exit and no bound. Measured at 250ms per million rows, linear, on the template thread. The pool that reported this runs a 5.5 GB shares database — order of a hundred million rows, half a minute per template — at which point it stops publishing work entirely. It now walks back in bounded batches, doubling until the batch covers the window, and the aggregate uses the primary-key index from that boundary. Same answer, same boundary rule (the share crossing it still counts whole, matching store_pplns_distribute exactly). 4,000,000 shares: 1033 ms -> 2.88 ms 8,000,000 shares: 1.08 ms 50,000 shares: 1.03 ms Flat in history size rather than linear. test_store.c covers the widening path, which is the part that could silently return a partial window: a window wider than the first batch, and one wider than the whole table. --- src/coinbase.c | 69 ++++++++++++++---- src/coinbase.h | 52 ++++++-------- src/store.c | 83 +++++++++++++++++++--- src/stratum.c | 13 ++-- tests/test_coinbase.c | 158 +++++++++++++++++++++++++++++++++--------- tests/test_store.c | 67 ++++++++++++++++++ 6 files changed, 350 insertions(+), 92 deletions(-) diff --git a/src/coinbase.c b/src/coinbase.c index 0a1e4bb..963e120 100644 --- a/src/coinbase.c +++ b/src/coinbase.c @@ -754,7 +754,7 @@ static size_t out_ser_size(size_t spk_len) { /* Turn a window into concrete outputs: fee off the top, the payout floor and * the byte budget applied largest-first, and whatever cannot be paid - * forfeited onto the operator output. + * redistributed across the miners who could be. * * Shared by the from-scratch and from-template builders precisely so the two * cannot drift. A pool mining a drivechain template and one mining plain @@ -838,14 +838,14 @@ static int resolve_window_outputs(int64_t value_sats, size_t n = 0; size_t payout_bytes = 0; - int64_t forfeited = 0; + int64_t dropped = 0; for (size_t k = 0; k < n_payees; ++k) { const coinbase_payee_t *pe = &payees[rank[k].idx]; if (pe->sats < payout_floor_sats) { - r.dropped_below_floor++; forfeited += pe->sats; continue; + r.dropped_below_floor++; dropped += pe->sats; continue; } if (n + 1 >= cap) { /* storage, not policy */ - r.dropped_capped++; forfeited += pe->sats; continue; + r.dropped_capped++; dropped += pe->sats; continue; } /* Resolve first: an output's cost depends on its address type, and a * P2TR payout is 43 bytes against a P2WPKH one's 31. Budgeting at a @@ -860,7 +860,7 @@ static int resolve_window_outputs(int64_t value_sats, /* No room. Keep going rather than breaking: a later payee may be * a cheaper address type and still fit, and dropping it would * forfeit money that could have been paid. */ - r.dropped_capped++; forfeited += pe->sats; continue; + r.dropped_capped++; dropped += pe->sats; continue; } payout_bytes += cost; out[n].sats = pe->sats; @@ -879,16 +879,60 @@ static int resolve_window_outputs(int64_t value_sats, return -1; } - /* Forfeits ride on the operator output, because value not paid out is - * value destroyed — a coinbase paying less than it may does not leave the - * remainder anywhere. They are the operator's income, not a debt: see - * coinbase_window_result_t. */ - int64_t operator_out = fee_sats + forfeited; + /* Whatever could not be paid is REDISTRIBUTED ACROSS THE MINERS WHO COULD. + * + * It used to go to the operator, on the reasoning that value not paid out + * is value destroyed and the operator output is the only place left. The + * first half is true; the second was wrong, and the measurement that + * settled it is worth keeping. With 100 miners on a 1/n hashrate spread + * and the default 1000-byte budget, 28 are paid, 72 are cut by the byte + * cap, NONE by the dust floor -- and the operator received 25% of the + * block on a 1% fee. The rule was defended as a dust policy and dust was + * never involved. + * + * Two things made it indefensible rather than merely harsh. A miner's + * window share tracks its hashrate, so the same miners fall below the + * cut every block: it pays them nothing ever, rather than occasionally. + * And it paid the operator MORE the smaller the coinbase, so an operator + * maximised revenue by starving its own miners -- 46% of the block at a + * 400-byte budget against 2% at 3000. + * + * Redistributing keeps every property the forfeit had. The block still + * pays out to the satoshi, the pool still holds nothing, and no ledger + * appears. What changes is only who receives what the coinbase had no + * room for: the other miners, not the house. + * + * (LayerTwo-Labs/simplepool#76, and Wired4ncer, who ran the pool that + * showed it.) */ + if (dropped > 0) { + /* Scale the survivors up to spend `payable` exactly. Amounts do not + * affect an output's size -- a value is 8 bytes whatever it holds -- + * so this cannot break the byte budget just measured. */ + int64_t assigned = 0; + for (size_t i = 0; i < n; ++i) { + out[i].sats = (int64_t)((double)payable * + ((double)out[i].sats / (double)r.paid_sats)); + assigned += out[i].sats; + } + /* Truncation again, and the same rule as everywhere else: the + * remainder goes to the largest surviving claim, which is out[0] + * because payees were resolved largest-first. */ + if (assigned < payable) out[0].sats += payable - assigned; + else if (assigned > payable) { + set_err(errbuf, errlen, "internal: redistribution overshot"); + return -1; + } + r.redistributed_sats = dropped; + r.paid_sats = payable; + } + + /* The operator now receives its fee and nothing else. */ + int64_t operator_out = fee_sats; if (operator_out > 0 && !has_operator) { if (!operator_address || !operator_address[0]) { set_err(errbuf, errlen, - "%lld sats could not be paid to the window and there is no " - "operator_address to receive them", (long long)operator_out); + "a %lld-sat operator fee has no operator_address to " + "receive it", (long long)operator_out); return -1; } if (coinbase_address_to_script(operator_address, op.spk, sizeof op.spk, @@ -902,7 +946,6 @@ static int resolve_window_outputs(int64_t value_sats, } r.fee_sats = fee_sats; - r.forfeited_sats = forfeited; if (out_n) *out_n = n; if (res) *res = r; return 0; diff --git a/src/coinbase.h b/src/coinbase.h index 428bb52..ef78deb 100644 --- a/src/coinbase.h +++ b/src/coinbase.h @@ -91,40 +91,34 @@ typedef struct { /* What the builder actually managed to pay, and what it could not. * - * `forfeited_sats` is the honest part. A payee below the payout floor, or - * past the byte budget, cannot be paid in THIS coinbase, and its value cannot - * simply vanish either: a coinbase that pays out less than it is allowed - * forfeits the difference to nobody. So the shortfall goes to the operator - * output — and it stays there. + * A coinbase has a fixed budget of bytes, so a window with more miners in it + * than the budget admits cannot pay them all in one block. `redistributed_sats` + * is what the ones it could not pay were owed. * - * That is the cost the design has to own, and it is a cost borne by the - * smallest miners rather than by the pool. The alternative was a carried - * balance, which is the custodial ledger this mode exists to delete: it - * reintroduces a debt, an off-chain record of it, and a settlement that can - * fail. Forfeiting instead keeps the property that the block IS the payment, - * at the price of a hard floor under who this pool is worth mining at. The - * number is right here so it can be disclosed rather than discovered. */ + * That value goes TO THE OTHER MINERS, not to the operator. The block still + * pays out to the satoshi and the pool still holds nothing; the only question + * a byte budget forces is which miners receive what it had no room for, and + * the honest answer is the rest of the window rather than the house. + * + * It used to go to the operator. See the long note at the redistribution in + * coinbase.c for the measurement that ended that: the rule was defended as a + * dust policy, and dust turned out not to be involved at all. */ typedef struct { - size_t paid_count; /* payees given an output */ - int64_t paid_sats; /* summed across those outputs */ + size_t paid_count; /* payees given an output */ + int64_t paid_sats; /* summed across those outputs */ size_t dropped_below_floor; /* payees under payout_floor_sats */ size_t dropped_capped; /* payees the byte budget had no room for */ - /* Claims the coinbase could not pay, which go to the operator. - * - * FORFEITED, not owed. This is a deliberate policy choice and not an - * accounting convenience: a coinbase-direct pool cannot pay an amount too - * small to be an economical output, and carrying it creates exactly the - * custodial balance the mode exists to remove. So a claim below the - * payout floor is not paid, is not remembered, and is not a debt — it - * becomes operator income. + /* What the dropped payees were owed, now spread across the ones that were + * paid. Reported so an operator can see how much of a block is landing on + * miners other than the ones who earned it -- a large figure here means + * the byte budget is too tight for the size of the pool, and is the number + * to raise coinbase_max_bytes against. * - * The consequence is real and has to be disclosed rather than discovered: - * a miner whose share never reaches the floor earns nothing, however long - * it mines. That is the intended incentive — a miner that small is better - * off mining solo — but it is only a rule rather than a trap if the miner - * can see it, which is why the floor is logged at startup and per block. */ - int64_t forfeited_sats; - int64_t fee_sats; /* the operator's fee, excluding forfeits */ + * Nobody is left out of pocket by a single block being unable to pay them, + * but nobody is made whole by it either: this is the unfairness the + * per-worker fraction ledger exists to even out over time. */ + int64_t redistributed_sats; + int64_t fee_sats; /* the operator's fee, and nothing else */ } coinbase_window_result_t; diff --git a/src/store.c b/src/store.c index ea41579..67c8e06 100644 --- a/src/store.c +++ b/src/store.c @@ -1458,16 +1458,79 @@ int store_pplns_window(store_t *s, double window_diff, * whole instead of splitting it. Same rule, same reason, as the * distributor -- if these two ever disagree, a block pays out differently * from what the template promised. */ + /* Find where the window starts, reading only as far back as it reaches. + * + * The obvious query -- a running SUM() OVER the whole shares table, with + * the window boundary in the WHERE -- computes the running total FIRST and + * filters afterwards, so there is no early exit and no bound: every + * template build re-reads every share the pool has ever recorded. + * Measured at 250ms per million rows, linear, on the template thread. A + * production pool reported a 5.5 GB shares database, which is on the order + * of a hundred million rows and half a minute per template -- the pool + * would simply stop publishing work (LayerTwo-Labs/simplepool#76). + * + * So walk backwards in bounded batches instead, doubling until the batch + * covers the window, and let the main query use the primary-key index from + * the boundary id. A window is a small multiple of one block's expected + * work, so the first batch almost always covers it; the loop exists for + * the pathological cases (a difficulty crash, a freshly-lowered window) + * rather than the normal one. + * + * The boundary rule is unchanged and must stay unchanged: `running - + * difficulty < window` counts the share that CROSSES the boundary whole, + * matching store_pplns_distribute() exactly. If these two ever disagree a + * block pays out differently from what its template promised. */ + static const char *QB = + "SELECT MIN(id), MAX(running) FROM (" + " SELECT id, difficulty," + " SUM(difficulty) OVER (ORDER BY id DESC ROWS UNBOUNDED PRECEDING) AS running" + " FROM (SELECT id, difficulty FROM shares ORDER BY id DESC LIMIT ?)" + ") WHERE running - difficulty < ?"; + + sqlite3_int64 cutoff_id = 0; + { + sqlite3_stmt *b = NULL; + if (sqlite3_prepare_v2(s->db, QB, -1, &b, NULL) != SQLITE_OK) { + if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); + atomic_fetch_add(&s->pg_errors, 1); + return -2; + } + /* 4096 covers a 2x window at any sane share difficulty; the cap stops + * a pool whose entire history is smaller than one window from looping + * forever doubling past the end of the table. */ + sqlite3_int64 batch = 4096; + for (;;) { + sqlite3_reset(b); + sqlite3_bind_int64(b, 1, batch); + sqlite3_bind_double(b, 2, window_diff); + if (sqlite3_step(b) != SQLITE_ROW) break; + if (sqlite3_column_type(b, 0) == SQLITE_NULL) break; /* no shares */ + cutoff_id = sqlite3_column_int64(b, 0); + double covered = sqlite3_column_double(b, 1); + /* Covered means the batch reached past the window. If it did not, + * the window extends further back than we read and the answer + * would silently be a partial window -- so widen and retry. */ + if (covered >= window_diff) break; + sqlite3_int64 seen = 0; + sqlite3_stmt *c = NULL; + if (sqlite3_prepare_v2(s->db, + "SELECT COUNT(*) FROM (SELECT 1 FROM shares LIMIT ?)", + -1, &c, NULL) == SQLITE_OK) { + sqlite3_bind_int64(c, 1, batch); + if (sqlite3_step(c) == SQLITE_ROW) seen = sqlite3_column_int64(c, 0); + sqlite3_finalize(c); + } + if (seen < batch) break; /* read the whole table already */ + batch *= 4; + } + sqlite3_finalize(b); + } + static const char *Q = - "WITH anchored AS (" - " SELECT worker_id, difficulty, " - " SUM(difficulty) OVER (ORDER BY id DESC ROWS UNBOUNDED PRECEDING) AS running " - " FROM shares " - ") " - "SELECT w.id, COALESCE(w.payout_address,''), SUM(a.difficulty) AS wd " - " FROM anchored a " - " JOIN workers w ON w.id = a.worker_id " - " WHERE a.running - a.difficulty < ? " + "SELECT w.id, COALESCE(w.payout_address,''), SUM(sh.difficulty) AS wd " + " FROM shares sh " + " JOIN workers w ON w.id = sh.worker_id " + " WHERE sh.id >= ? " " AND w.payout_address IS NOT NULL AND w.payout_address <> '' " " GROUP BY w.id " " HAVING wd > 0 " @@ -1479,7 +1542,7 @@ int store_pplns_window(store_t *s, double window_diff, atomic_fetch_add(&s->pg_errors, 1); return -2; } - sqlite3_bind_double(st, 1, window_diff); + sqlite3_bind_int64(st, 1, cutoff_id); size_t n = 0; double total = 0.0; diff --git a/src/stratum.c b/src/stratum.c index 8cc535b..406605c 100644 --- a/src/stratum.c +++ b/src/stratum.c @@ -2179,14 +2179,15 @@ static int submit_with_job(stratum_server_t *s, stratum_conn_t *c, cJSON *id, size_t dropped = res.dropped_below_floor + res.dropped_capped; if (dropped > 0) { LOG_INFO("pplns-coinbase: block %s paid %zu miner(s) %lld " - "sats directly; %zu claim(s) worth %lld sats were " - "forfeited to the operator (%zu below the %lld-sat " - "payout floor, %zu with no room in a %zu-byte " - "coinbase). Forfeited claims are NOT carried and are " - "not settled later.", + "sats directly; %zu claim(s) worth %lld sats had no " + "room and were REDISTRIBUTED across the miners who " + "did fit (%zu below the %lld-sat floor, %zu past the " + "%zu-byte coinbase). The operator took its fee and " + "nothing more. A large figure here means the byte " + "budget is too tight for this many miners.", block_hash_hex, res.paid_count, (long long)res.paid_sats, dropped, - (long long)res.forfeited_sats, + (long long)res.redistributed_sats, res.dropped_below_floor, (long long)s->cfg.payout_floor_sats, res.dropped_capped, diff --git a/tests/test_coinbase.c b/tests/test_coinbase.c index 5606edf..fe6fde2 100644 --- a/tests/test_coinbase.c +++ b/tests/test_coinbase.c @@ -841,7 +841,7 @@ static void test_window_pays_each_miner_its_own_output(void) { assert(rc == 0); assert(res.paid_count == 3); assert(res.fee_sats == 50000000LL); - assert(res.forfeited_sats == 0); + assert(res.redistributed_sats == 0); assert(res.paid_sats == 4950000000LL); uint64_t n = 0; int64_t sum = 0; @@ -871,12 +871,17 @@ static void test_a_split_that_does_not_add_up_is_refused(void) { printf("ok: a window split that does not sum to the block is refused\n"); } -/* Below the floor. The value has to go somewhere -- a coinbase paying out - * less than it may forfeits the difference to nobody -- and the somewhere is - * the operator output. It is income, not a debt: nothing records it and - * nothing settles it later. This is the design's harshest edge, so it is - * pinned rather than left implied. */ -static void test_a_payee_below_the_floor_is_forfeited_to_the_operator(void) { +/* Below the floor. The value has to go somewhere -- a coinbase paying out less + * than it may forfeits the difference to nobody -- and the somewhere is the + * OTHER MINERS, not the operator. + * + * This assertion was the other way round until #76. The rule was defended as a + * dust policy; measurement showed the byte cap, not dust, was doing the + * excluding, and that the operator was collecting a quarter of the block on a + * 1% fee. The test now pins the property that makes the mode defensible: the + * operator receives its fee and nothing else, whatever the coinbase could not + * fit. */ +static void test_a_payee_below_the_floor_is_shared_out_not_given_to_the_operator(void) { coinbase_parts_t parts; char err[256]; coinbase_window_result_t res; /* fee 1% of 100,000,000 = 1,000,000; payable 99,000,000. */ @@ -890,20 +895,64 @@ static void test_a_payee_below_the_floor_is_forfeited_to_the_operator(void) { assert(rc == 0); assert(res.paid_count == 1); assert(res.dropped_below_floor == 1); - assert(res.forfeited_sats == 100LL); - /* Forfeits are reported apart from the fee. The operator output carries - * both, but an operator publishing its take has to be able to say which - * part was the advertised fee and which part was somebody's lost claim. */ + assert(res.redistributed_sats == 100LL); + /* The survivor absorbs it: it is paid the whole payable amount. */ + assert(res.paid_sats == 99000000LL); + /* And the operator gets its fee, to the satoshi, and nothing else. */ assert(res.fee_sats == 1000000LL); uint64_t n = 0; int64_t sum = 0; window_outputs(&parts, 12, &n, &sum); assert(n == 2); /* one miner + the operator */ assert(sum == 100000000LL); /* still the whole block */ - /* And the operator output is fee + forfeit, not just the fee. */ - assert(sum - res.paid_sats == res.fee_sats + res.forfeited_sats); + assert(sum - res.paid_sats == res.fee_sats); coinbase_parts_free(&parts); - printf("ok: a payee below the floor is forfeited to the operator\n"); + printf("ok: a payee below the floor is shared out, not given to the operator\n"); +} + +/* The property the old rule broke, stated on its own: an operator cannot + * increase its take by tightening the coinbase. + * + * Under forfeit-to-operator this was the whole problem -- a 400-byte budget + * paid the operator 46%% of the block against 2%% at 3000, so starving your own + * miners was the revenue-maximising move. Now the fee is the fee at every + * budget, and the only thing a tighter coinbase changes is how many miners + * share the block. */ +static void test_the_operator_cannot_profit_by_shrinking_the_coinbase(void) { + enum { N = 40 }; + coinbase_payee_t payees[N]; + int64_t payable = 4950000000LL, tot = 0; + double w[N], tw = 0; + for (int i = 0; i < N; ++i) { w[i] = 1.0 / (i + 1); tw += w[i]; } + for (int i = 0; i < N; ++i) { + payees[i].address = (i % 2) ? WA : WB; + payees[i].sats = (int64_t)((double)payable * (w[i] / tw)); + tot += payees[i].sats; + } + payees[0].sats += payable - tot; + + size_t budgets[] = { 400, 600, 1000, 2000 }; + size_t seen_paid = 0; + for (size_t b = 0; b < sizeof budgets / sizeof budgets[0]; ++b) { + coinbase_parts_t parts; char err[256]; + coinbase_window_result_t res; + assert(coinbase_build_window(800000, 5000000000LL, payees, N, WOP, 100, + NULL, "/sp/", 4, 8, budgets[b], 546, + &parts, &res, err, sizeof err) == 0); + /* The fee never moves, whatever the budget does. */ + assert(res.fee_sats == 50000000LL); + /* The miners always receive the entire rest of the block. */ + assert(res.paid_sats == payable); + uint64_t n = 0; int64_t sum = 0; + window_outputs(&parts, 12, &n, &sum); + assert(sum == 5000000000LL); + /* A bigger budget pays strictly more miners -- that is the only + * thing it buys. */ + assert(res.paid_count >= seen_paid); + seen_paid = res.paid_count; + coinbase_parts_free(&parts); + } + printf("ok: the operator's take is its fee at every byte budget\n"); } /* The floor is configurable, and raising it forfeits claims that the dust @@ -920,7 +969,7 @@ static void test_the_payout_floor_is_configurable(void) { WOP, 0, NULL, NULL, 4, 8, 0, 0, &parts, &res, err, sizeof err) == 0); assert(res.paid_count == 2); - assert(res.forfeited_sats == 0); + assert(res.redistributed_sats == 0); coinbase_parts_free(&parts); /* Floor above the small claim: it is forfeited, not carried. */ @@ -929,7 +978,7 @@ static void test_the_payout_floor_is_configurable(void) { 0, 50000, &parts, &res, err, sizeof err) == 0); assert(res.paid_count == 1); assert(res.dropped_below_floor == 1); - assert(res.forfeited_sats == 10000LL); + assert(res.redistributed_sats == 10000LL); coinbase_parts_free(&parts); /* A floor below the dust limit is clamped up to it rather than honoured: @@ -971,27 +1020,63 @@ static void test_the_cap_falls_on_the_smallest_claims(void) { assert(rc == 0); assert(res.paid_count == 2); assert(res.dropped_capped == 1); - /* The 1,000,000 claim is the one that loses out, not the 6,000,000 one. */ - assert(res.forfeited_sats == 1000000LL); - assert(res.paid_sats == 9000000LL); + /* The 1,000,000 claim is the one dropped, and its share goes to the two + * that fit — not to the operator. */ + assert(res.redistributed_sats == 1000000LL); + assert(res.paid_sats == 10000000LL); /* the whole payable amount */ coinbase_parts_free(&parts); printf("ok: the output cap drops the smallest claims first\n"); } -/* With no operator address there is nowhere for a forfeit to go, so a window - * that cannot be paid in full has to be refused rather than silently burn the - * difference into the void. */ -static void test_a_forfeit_without_an_operator_address_is_refused(void) { +/* A window that cannot be paid in full no longer needs an operator address at + * all, because nothing lands on the operator any more. + * + * This test used to assert the opposite -- that such a build is refused, + * because the forfeit had nowhere to go. Redistribution removes the whole + * problem: the survivors absorb it, and a pool running with no operator + * address and no fee can still pay a window bigger than its coinbase. */ +static void test_a_dropped_claim_needs_no_operator_address(void) { coinbase_parts_t parts; char err[256]; + coinbase_window_result_t res; const coinbase_payee_t payees[] = { - { WA, 999900LL }, { WB, 100LL }, + { WA, 999900LL }, { WB, 100LL }, /* the second is dust */ }; int rc = coinbase_build_window(800000, 1000000LL, payees, 2, NULL, 0, NULL, NULL, 4, 8, - 0, 0, &parts, NULL, err, sizeof err); - assert(rc < 0); - assert(strstr(err, "no operator_address to receive") != NULL); - printf("ok: a forfeit with nowhere to go is refused, not burnt\n"); + 0, 0, &parts, &res, err, sizeof err); + assert(rc == 0); + assert(res.dropped_below_floor == 1); + assert(res.redistributed_sats == 100LL); + assert(res.fee_sats == 0); + /* One output, holding the entire block. */ + uint64_t n = 0; int64_t sum = 0; + window_outputs(&parts, 12, &n, &sum); + assert(n == 1); + assert(sum == 1000000LL); + assert(res.paid_sats == 1000000LL); + coinbase_parts_free(&parts); + printf("ok: a dropped claim needs no operator address — it goes to the miners\n"); +} + +/* With no operator address there is no fee, whatever fee_bps says — so the + * miners take the entire block. Worth pinning because it is now the ONLY + * thing operator_address affects in this mode: since dropped claims go to the + * other miners rather than the operator, a pool can run without one. */ +static void test_no_operator_address_means_no_fee(void) { + coinbase_parts_t parts; char err[256]; + coinbase_window_result_t res; + const coinbase_payee_t payees[] = { { WA, 990000LL }, { WB, 10000LL } }; + int rc = coinbase_build_window(800000, 1000000LL, payees, 2, + NULL, 100, NULL, NULL, 4, 8, + 0, 0, &parts, &res, err, sizeof err); + assert(rc == 0); + assert(res.fee_sats == 0); /* fee_bps=100 but nowhere to pay */ + assert(res.paid_sats == 1000000LL); /* so the miners take all of it */ + uint64_t n = 0; int64_t sum = 0; + window_outputs(&parts, 12, &n, &sum); + assert(n == 2 && sum == 1000000LL); + coinbase_parts_free(&parts); + printf("ok: no operator address means no fee, and the miners take the block\n"); } /* If nobody clears the floor, paying the operator the whole block and calling @@ -1122,7 +1207,7 @@ static void test_window_from_template_preserves_commitments(void) { if (rc != 0) fprintf(stderr, "window_from_template err: %s\n", err); assert(rc == 0); assert(res.paid_count == 2); - assert(res.forfeited_sats == 0); + assert(res.redistributed_sats == 0); assert(res.paid_sats == reward); /* The enforcer's own outputs must survive: one spendable output was @@ -1214,10 +1299,10 @@ static void test_both_window_builders_split_identically(void) { assert(r1.paid_count == r2.paid_count); assert(r1.paid_sats == r2.paid_sats); assert(r1.fee_sats == r2.fee_sats); - assert(r1.forfeited_sats == r2.forfeited_sats); + assert(r1.redistributed_sats == r2.redistributed_sats); assert(r1.dropped_below_floor == r2.dropped_below_floor); assert(r1.dropped_below_floor == 1); - assert(r1.forfeited_sats >= 100); + assert(r1.redistributed_sats >= 100); coinbase_parts_free(&p1); coinbase_parts_free(&p2); printf("ok: both window builders split a window identically\n"); @@ -1270,7 +1355,10 @@ static void test_commitments_eat_the_payout_budget(void) { /* The block is still fully spent: whatever did not fit was forfeited to * the operator rather than left unpaid in the coinbase. */ assert(res.dropped_capped == N - paid_with_template); - assert(res.paid_sats + res.forfeited_sats + res.fee_sats == reward); + /* Redistribution means the miners get the whole payable amount, so the + * block is exactly the miners' share plus the fee. */ + assert(res.paid_sats + res.fee_sats == reward); + assert(res.redistributed_sats > 0); coinbase_parts_free(&parts); /* The same window and the same budget, built from scratch — no template, @@ -1325,10 +1413,12 @@ int main(void) { test_window_from_template_preserves_commitments(); test_window_pays_each_miner_its_own_output(); test_a_split_that_does_not_add_up_is_refused(); - test_a_payee_below_the_floor_is_forfeited_to_the_operator(); + test_a_payee_below_the_floor_is_shared_out_not_given_to_the_operator(); + test_the_operator_cannot_profit_by_shrinking_the_coinbase(); test_the_payout_floor_is_configurable(); test_the_cap_falls_on_the_smallest_claims(); - test_a_forfeit_without_an_operator_address_is_refused(); + test_a_dropped_claim_needs_no_operator_address(); + test_no_operator_address_means_no_fee(); test_a_window_of_only_dust_is_refused(); test_an_empty_window_is_refused(); test_the_witness_commitment_is_preserved(); diff --git a/tests/test_store.c b/tests/test_store.c index 23ab8e6..f887b75 100644 --- a/tests/test_store.c +++ b/tests/test_store.c @@ -1494,6 +1494,72 @@ static void test_the_payout_floor_is_published_for_the_dashboard(void) { printf(" ok test_the_payout_floor_is_published_for_the_dashboard\n"); } +/* The window query must read only as far back as the window reaches. + * + * The version this replaced summed a running total over the WHOLE shares table + * and applied the boundary afterwards, so every template build re-read every + * share the pool had ever recorded: 250ms per million rows, on the template + * thread. A production pool reported a 5.5 GB database, where that is half a + * minute per template and the pool simply stops publishing work. + * + * The replacement walks back in bounded batches, doubling until the batch + * covers the window. These tests exist for the doubling, because that is the + * part that can silently return a PARTIAL window -- which would not error, it + * would just pay the wrong people. */ +static void test_the_window_reads_past_the_first_batch(void) { + const char *path = fresh_db_path(); + store_cfg_t cfg = {0}; + snprintf(cfg.path, sizeof(cfg.path), "%s", path); + store_t *s = NULL; + assert(store_open(&cfg, &s) == 0); + + /* 10,000 shares of difficulty 1 across 4 workers. The first batch is + * 4096, so a 6000-wide window MUST make the walk widen; if it did not, + * the total would come back as 4096 and nobody would notice but the + * miners. */ + sqlite3 *db = NULL; + assert(sqlite3_open(path, &db) == SQLITE_OK); + sqlite3_exec(db, "BEGIN", NULL, NULL, NULL); + for (int i = 1; i <= 4; ++i) { + char q[256]; + snprintf(q, sizeof q, + "INSERT INTO workers (id,name,payout_address,first_seen,last_seen)" + " VALUES (%d,'w%d','bc1qw%d',1,1)", i, i, i); + assert(sqlite3_exec(db, q, NULL, NULL, NULL) == SQLITE_OK); + } + for (int i = 0; i < 10000; ++i) { + char q[160]; + snprintf(q, sizeof q, + "INSERT INTO shares (worker_id,ts,difficulty) VALUES (%d,1,1.0)", + (i % 4) + 1); + assert(sqlite3_exec(db, q, NULL, NULL, NULL) == SQLITE_OK); + } + sqlite3_exec(db, "COMMIT", NULL, NULL, NULL); + sqlite3_close(db); + + store_window_entry_t win[16]; + size_t n = 0; double total = 0; int truncated = 0; char err[256]; + + /* Inside the first batch. */ + assert(store_pplns_window(s, 1000.0, win, 16, &n, &total, &truncated, + err, sizeof err) > 0); + assert(total == 1000.0); + + /* Past it — this is the case the doubling exists for. */ + assert(store_pplns_window(s, 6000.0, win, 16, &n, &total, &truncated, + err, sizeof err) > 0); + assert(total == 6000.0); + + /* Wider than the entire history: every share, and no infinite loop + * doubling past the end of the table. */ + assert(store_pplns_window(s, 999999.0, win, 16, &n, &total, &truncated, + err, sizeof err) > 0); + assert(total == 10000.0); + + store_close(s); + printf(" ok test_the_window_reads_past_the_first_batch\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(); @@ -1548,6 +1614,7 @@ int main(void) { test_pplns_distributes_the_window(); test_pplns_takes_the_operator_fee(); test_the_payout_floor_is_published_for_the_dashboard(); + test_the_window_reads_past_the_first_batch(); test_pplns_distributes_two_blocks_in_one_pass(); test_an_empty_window_returns_nothing_not_an_error(); test_a_window_wider_than_the_cap_says_so(); From 3ed58b7124071115cdac228063682a26aa5edba9 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 8 Sep 2026 23:18:00 +0200 Subject: [PATCH 19/36] pplns-coinbase: a payout queue, so being small stops meaning being skipped for ever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third of Wired4ncer's three points, and the one that needed a design rather than a fix. Redistribution stopped the operator taking the money a coinbase had no room for, but it did not change WHO the coinbase has room for: a miner's window share tracks its hashrate, so the largest claims take the same slots every block and the same addresses are never paid. His measurement on a production pool: over 31 blocks, 279 payout slots reached 34 addresses, 12 of which took 91% of them, while 88 addresses got nothing — and 28 of those cleared the payout floor comfortably. The floor was not what excluded them, and no additive ranking fixes it, because a large miner's share of the current window beats any priority a small one can accumulate. So a fraction of the slots is reserved outright for whoever has waited longest. Costs no coinbase bytes, changes nobody's total, changes only how often people are paid. What is remembered, and what is not: - a signed fraction of ONE block reward per worker, in pplns_fractions. Positive means skipped and first in the queue; negative means paid early out of somebody else's skipped share. The column sums to zero. - it is NOT a balance and the pool holds nothing against it. Nothing is withheld from a coinbase and released later — that would need a block paying less than the reward followed by one paying more, and the second is invalid. Delete the table and nobody is owed a payment; the pool just forgets whose turn it was. - fractions rather than difficulty, because shares stay in the window across several blocks (rolling unpaid difficulty forward counts the same work twice) and difficulty is not comparable across a retarget. Orphans get this right. Deltas are staged against the block hash when a block is found, which is a CANDIDATE, and the confirmation pass applies them only once the block is confirmed — discarding them if it is orphaned. Applying at found time would record a rotation that never happened and move a miner down the queue for a payment it never received. Two bugs found writing it, both mine: - the builder sorted payees largest-first internally, so no ordering policy was expressible at all. It now pays down the order the caller gives, and the rounding remainder goes to the largest claim PAID rather than to index 0, which was only the largest while the builder did its own sort. - the first delta computation divided by res.paid_sats, which redistribution sets to the whole payable amount — so every delta came out as exactly zero and the queue silently recorded nobody. Caught by the e2e, which asserts the queue is non-empty after a block that skipped someone rather than merely that it balances. A test for zero-sum alone would have passed. Verified end to end: a 100:10:1 window on a real chain pays the two that fit, redistributes the third's 44,594,596 sats across them, leaves the operator holding its 50,000,000-sat fee to the satoshi, and stages 3 queue rows summing to zero with the skipped miner owed 9/1000 of a block. Full C suite, ASan, all six regtest suites, both node suites. --- schema.sql | 47 ++++++++ src/coinbase.c | 80 +++++++++----- src/coinbase.h | 15 +++ src/main.c | 55 ++++++++- src/pplns.c | 65 +++++++++++ src/pplns.h | 43 +++++++ src/reconcile.c | 25 +++++ src/reconcile.h | 3 + src/store.c | 160 ++++++++++++++++++++++++++- src/store.h | 42 +++++++ src/stratum.c | 70 +++++++++++- src/stratum.h | 15 ++- tests/test_coinbase.c | 56 ++++++++-- tests/test_pplns.c | 134 +++++++++++++++++++--- tests/test_pplns_coinbase_regtest.sh | 101 ++++++++++++----- tests/test_store.c | 159 ++++++++++++++++++++++++++ tests/test_stratum.c | 2 +- 17 files changed, 986 insertions(+), 86 deletions(-) diff --git a/schema.sql b/schema.sql index 6db1874..cc3f6d9 100644 --- a/schema.sql +++ b/schema.sql @@ -329,3 +329,50 @@ CREATE TABLE IF NOT EXISTS tx_attempts ( ); CREATE INDEX IF NOT EXISTS tx_attempts_ts_idx ON tx_attempts(ts); CREATE INDEX IF NOT EXISTS tx_attempts_kind_idx ON tx_attempts(kind, ts); + +-- pplns-coinbase: who has been skipped, and by how much. +-- +-- A signed fraction of ONE block reward per worker. Positive means the miner +-- was skipped -- the coinbase had no room for it -- and is first in the queue +-- for a slot in a future block. Negative means it was paid early, out of +-- somebody else's skipped share, and waits its turn. The column sums to zero +-- across the table. +-- +-- THIS IS NOT A BALANCE AND THE POOL HOLDS NO MONEY AGAINST IT. Nothing is +-- ever withheld from a coinbase and released later; that would need a block +-- paying less than the reward followed by one paying more, and the second is +-- invalid. It is a memory of who was skipped, used only to order the next +-- block's payouts. If this file were deleted nobody would be owed a payment, +-- because nobody was ever holding one -- the pool would simply forget whose +-- turn it was. +-- +-- Recorded as a fraction rather than in difficulty units on purpose. Shares +-- stay in the window across several blocks, so rolling unpaid difficulty +-- forward would count the same work twice; and difficulty is not comparable +-- over time -- it swings, and it resets at a fork -- so a claim stored in +-- difficulty units quietly changes meaning at every retarget. A fraction of a +-- block reward does not. +CREATE TABLE IF NOT EXISTS pplns_fractions ( + worker_id INTEGER PRIMARY KEY REFERENCES workers(id), + owed_fraction REAL NOT NULL DEFAULT 0, + updated_at INTEGER +); + +-- What a block's coinbase did to those fractions, held until the block is +-- known to have survived. +-- +-- Written when a block is found, which is a CANDIDATE: submitblock may have +-- refused it, or the chain may reorg it away, and either way its coinbase +-- paid nobody. Applying the deltas there would record a rotation that never +-- happened and quietly move somebody down the queue for a payment they never +-- received. The confirmation pass applies these when the block is confirmed +-- and deletes them when it is orphaned -- the same rule, and the same reason, +-- as PPLNS distribution. +CREATE TABLE IF NOT EXISTS pplns_pending_fractions ( + block_hash TEXT NOT NULL, + worker_id INTEGER NOT NULL, + delta REAL NOT NULL, + PRIMARY KEY (block_hash, worker_id) +); +CREATE INDEX IF NOT EXISTS pplns_pending_hash_idx + ON pplns_pending_fractions(block_hash); diff --git a/src/coinbase.c b/src/coinbase.c index 963e120..f94ba3d 100644 --- a/src/coinbase.c +++ b/src/coinbase.c @@ -713,18 +713,6 @@ static int rd_u64(const uint8_t *buf, size_t len, size_t *off, uint64_t *val) { /* ---------- coinbase-direct PPLNS ---------- */ -/* Sort helper: largest claim first, ties broken by original position so the - * output order is deterministic for a given window. A stable, reproducible - * coinbase matters -- a miner checking the block it was paid from should get - * the same answer twice. */ -typedef struct { size_t idx; int64_t sats; } payee_rank_t; - -static int payee_rank_cmp(const void *a, const void *b) { - const payee_rank_t *x = a, *y = b; - if (x->sats != y->sats) return x->sats > y->sats ? -1 : 1; - return x->idx < y->idx ? -1 : (x->idx > y->idx ? 1 : 0); -} - /* One concrete output the reward is being replaced with. */ typedef struct { uint8_t spk[64]; @@ -828,19 +816,24 @@ static int resolve_window_outputs(int64_t value_sats, return -1; } - payee_rank_t *rank = calloc(n_payees, sizeof *rank); - if (!rank) { set_err(errbuf, errlen, "oom"); return -1; } - for (size_t i = 0; i < n_payees; ++i) { - rank[i].idx = i; - rank[i].sats = payees[i].sats; - } - qsort(rank, n_payees, sizeof *rank, payee_rank_cmp); - + /* Paid in the order the CALLER gave, not in one chosen here. + * + * This used to sort largest-first internally, so the floor and the byte + * budget always fell on the smallest claims. That is the right default and + * it is still what pplns.c hands over -- but it can only ever be a + * default, because "who gets the slots a coinbase has room for" is policy, + * and a policy fixed inside the builder cannot be changed without changing + * the builder. Specifically it made the slots unwinnable: a large miner's + * share of the window beats any priority a small one can accumulate, so + * the same addresses take the same slots every block for ever. + * + * The caller now owns the order and this pays greedily down it. See + * pplns_order_claims(). */ size_t n = 0; size_t payout_bytes = 0; int64_t dropped = 0; for (size_t k = 0; k < n_payees; ++k) { - const coinbase_payee_t *pe = &payees[rank[k].idx]; + const coinbase_payee_t *pe = &payees[k]; if (pe->sats < payout_floor_sats) { r.dropped_below_floor++; dropped += pe->sats; continue; } @@ -853,7 +846,7 @@ static int resolve_window_outputs(int64_t value_sats, if (coinbase_address_to_script(pe->address, out[n].spk, sizeof out[n].spk, &out[n].spk_len, errbuf, errlen) < 0) { - free(rank); return -1; + return -1; } size_t cost = out_ser_size(out[n].spk_len); if (payout_bytes + cost > payout_budget) { @@ -867,7 +860,6 @@ static int resolve_window_outputs(int64_t value_sats, r.paid_sats += pe->sats; n++; r.paid_count++; } - free(rank); if (r.paid_count == 0) { set_err(errbuf, errlen, @@ -915,9 +907,16 @@ static int resolve_window_outputs(int64_t value_sats, assigned += out[i].sats; } /* Truncation again, and the same rule as everywhere else: the - * remainder goes to the largest surviving claim, which is out[0] - * because payees were resolved largest-first. */ - if (assigned < payable) out[0].sats += payable - assigned; + * remainder goes to the largest surviving claim. Found by scanning + * rather than assumed to be out[0] -- that was only true while this + * function did its own largest-first sort, and a caller-supplied + * order can put anyone first. */ + if (assigned < payable) { + size_t big = 0; + for (size_t i = 1; i < n; ++i) + if (out[i].sats > out[big].sats) big = i; + out[big].sats += payable - assigned; + } else if (assigned > payable) { set_err(errbuf, errlen, "internal: redistribution overshot"); return -1; @@ -1456,6 +1455,35 @@ static int cb_reward_probe(void *ctx, int64_t reward_sats, size_t fixed_bytes, cb_repl_out_t *out, size_t cap, size_t *out_n, char *errbuf, size_t errlen); +size_t coinbase_expected_payout_slots(size_t max_coinbase_bytes, + const char *coinbase_tx_hex) +{ + size_t budget = max_coinbase_bytes ? max_coinbase_bytes + : (size_t)COINBASE_DEFAULT_MAX_BYTES; + /* The envelope the builder always pays: version, input, scriptSig with a + * generous extranonce and tag, output count, locktime, and a reserved + * operator output. Deliberately on the pessimistic side -- reserving one + * slot too few costs a rotation, reserving one too many costs a payout. */ + size_t fixed = 160; + if (coinbase_tx_hex) { + /* Everything the template already spends: its commitments, and the + * output being replaced. Counted from the transaction rather than + * guessed, because the same 16 payouts cost 817 bytes against four + * drivechain OP_RETURNs and 769 against three. */ + size_t hexlen = strlen(coinbase_tx_hex); + fixed += hexlen / 2; + /* The spendable output goes away as the payouts arrive, so do not + * charge for it twice. One P2WPKH-shaped output, approximately. */ + if (fixed > 31) fixed -= 31; + } + if (budget <= fixed) return 1; + /* 31 bytes is a P2WPKH payout, the common case. */ + size_t slots = (budget - fixed) / 31; + if (slots < 1) slots = 1; + if (slots > COINBASE_MAX_PAYOUT_OUTPUTS) slots = COINBASE_MAX_PAYOUT_OUTPUTS; + return slots; +} + int coinbase_template_reward(const char *coinbase_tx_hex, int64_t *out_sats) { if (!coinbase_tx_hex || !out_sats) return -1; int spendable = 0; diff --git a/src/coinbase.h b/src/coinbase.h index ef78deb..9f9da2e 100644 --- a/src/coinbase.h +++ b/src/coinbase.h @@ -199,6 +199,21 @@ void coinbase_parts_free(coinbase_parts_t *p); * enforcer (plus the mandatory BIP300/301 commitments), which is what tells an * observer whether a sidechain can be merge-mined into these blocks. * Returns 0 ok, negative on malformed input. */ +/* Roughly how many payouts a coinbase of `max_coinbase_bytes` will hold. + * + * An estimate, and only used to decide how many payout slots to reserve for + * long-waiting miners — the real limit is applied by the builder, against the + * actual address types and the actual template. Getting this wrong changes the + * fairness of the rotation and never the arithmetic: everyone still receives + * their own claim, and whatever the budget cuts is still redistributed. + * + * `coinbase_tx_hex` may be NULL, for a coinbase built from scratch; when it is + * given, its existing outputs are charged against the budget the way the + * builder charges them, because on a drivechain the commitment OP_RETURNs are + * what actually decide how many miners fit. */ +size_t coinbase_expected_payout_slots(size_t max_coinbase_bytes, + const char *coinbase_tx_hex); + /* The reward a server-provided coinbasetxn actually pays, in sats: the value * of its single spendable output, which is the one the window replaces. * diff --git a/src/main.c b/src/main.c index d1868f1..35bc9cb 100644 --- a/src/main.c +++ b/src/main.c @@ -181,6 +181,32 @@ static double effective_pps_rate(const proxy_config_t *cfg, return pps_rate_from_template(value_sats, net_diff, cfg->fee_bps); } +/* Stage what a found block's coinbase did to the payout queue. + * + * Staged, not applied: this block is a candidate, and a block that never + * stands rotated nobody. reconcile_blocks_pass() applies these once the block + * is confirmed and discards them if it is orphaned. */ +static void on_window_fractions_cb(void *ctx, const char *block_hash, + const struct store_fraction_delta *deltas, + size_t n) { + server_ctx_t *s = (server_ctx_t *)ctx; + if (!s || !s->store || !deltas || n == 0) return; + char ferr[256] = {0}; + int rc = store_stage_block_fractions(s->store, block_hash, deltas, n, + ferr, sizeof ferr); + if (rc < 0) { + /* Not fatal — the block is paid either way, this only decides whose + * turn is next. Worth a warning because a queue that stops recording + * silently reverts to "largest claim always wins". */ + LOG_WARN("pplns-coinbase: could not record the payout queue for block " + "%.16s: %s — rotation for this block is lost", + block_hash ? block_hash : "?", ferr); + return; + } + LOG_DEBUG("pplns-coinbase: staged %d payout-queue row(s) for block %.16s", + rc, block_hash ? block_hash : "?"); +} + /* Snapshot the PPLNS window onto a freshly built job, for pplns-coinbase. * * The window is taken from the template that is about to go out, so the @@ -289,13 +315,34 @@ static int attach_pplns_window(store_t *store, const proxy_config_t *cfg, for (size_t i = 0; i < n; ++i) { claims[i].payout_address = win[i].payout_address; claims[i].difficulty = win[i].difficulty; + claims[i].worker_id = win[i].worker_id; + claims[i].owed_fraction = win[i].owed_fraction; } + /* Decide who gets the slots before deciding what they are worth. + * + * A coinbase has room for a bounded number of payouts, and paying the + * largest claims first — which is what the builder used to do on its own — + * hands the same addresses the same slots every block, because a large + * miner's share of the window beats any priority a small one can + * accumulate. A fraction of the slots is therefore reserved for whoever + * has waited longest. Costs no bytes, changes nobody's total, changes only + * how often people are paid. */ + size_t expected_slots = coinbase_expected_payout_slots( + (size_t)cfg->coinbase_max_bytes, t->coinbasetxn_hex); + size_t order[COINBASE_MAX_PAYOUT_OUTPUTS]; + if (pplns_order_claims(claims, n, expected_slots, order) < 0) { + LOG_WARN("pplns-coinbase: could not order the window for payment"); + return -1; + } + pplns_claim_t ordered[COINBASE_MAX_PAYOUT_OUTPUTS]; + for (size_t i = 0; i < n; ++i) ordered[i] = claims[order[i]]; + coinbase_payee_t payees[COINBASE_MAX_PAYOUT_OUTPUTS]; pplns_split_t split; char serr[256] = {0}; if (pplns_split_window(value, cfg->fee_bps, cfg->operator_address[0] != 0, - claims, n, total, cfg->pplns_payout_floor_sats, + ordered, n, total, cfg->pplns_payout_floor_sats, payees, COINBASE_MAX_PAYOUT_OUTPUTS, &split, serr, sizeof serr) < 0) { LOG_WARN("pplns-coinbase: cannot split this block across the window: " @@ -303,7 +350,10 @@ static int attach_pplns_window(store_t *store, const proxy_config_t *cfg, return -1; } - if (stratum_job_set_window(job, payees, n) < 0) { + int64_t worker_ids[COINBASE_MAX_PAYOUT_OUTPUTS]; + for (size_t i = 0; i < n; ++i) worker_ids[i] = ordered[i].worker_id; + + if (stratum_job_set_window(job, payees, worker_ids, n) < 0) { LOG_WARN("pplns-coinbase: could not attach the window to the job"); return -1; } @@ -1370,6 +1420,7 @@ int main(int argc, char **argv) { stcfg.coinbase_pays_pool = mode_pps_classic || mode_pplns_thunder || mode_pplns_btc; stcfg.coinbase_pays_window = mode_pplns_cb; + stcfg.on_window_fractions = mode_pplns_cb ? on_window_fractions_cb : NULL; stcfg.max_coinbase_bytes = (size_t)cfg.coinbase_max_bytes; stcfg.payout_floor_sats = cfg.pplns_payout_floor_sats; stcfg.username_is_thunder = mode_pps_classic || mode_pplns_thunder; diff --git a/src/pplns.c b/src/pplns.c index f9026a3..1e39ec5 100644 --- a/src/pplns.c +++ b/src/pplns.c @@ -3,6 +3,7 @@ #include "pplns.h" #include +#include #include static void set_err(char *errbuf, size_t errlen, const char *msg) { @@ -92,3 +93,67 @@ int pplns_split_window(int64_t reward_sats, int fee_bps, int have_operator, if (res) *res = r; return 0; } + + +/* ---- payment order ------------------------------------------------------ */ + +typedef struct { size_t idx; double key; } rank_t; + +static int rank_desc(const void *a, const void *b) { + const rank_t *x = a, *y = b; + if (x->key < y->key) return 1; + if (x->key > y->key) return -1; + /* Ties by original position, so the same window always produces the same + * coinbase -- a miner checking the block it was paid from has to get the + * same answer we did. */ + return x->idx < y->idx ? -1 : (x->idx > y->idx ? 1 : 0); +} + +int pplns_order_claims(const pplns_claim_t *claims, size_t n_claims, + size_t expected_slots, size_t *order) +{ + if (!claims || !order || n_claims == 0) return -1; + + rank_t *by_size = calloc(n_claims, sizeof *by_size); + rank_t *by_owed = calloc(n_claims, sizeof *by_owed); + char *placed = calloc(n_claims, 1); + if (!by_size || !by_owed || !placed) { + free(by_size); free(by_owed); free(placed); return -1; + } + for (size_t i = 0; i < n_claims; ++i) { + by_size[i].idx = i; by_size[i].key = claims[i].difficulty; + by_owed[i].idx = i; by_owed[i].key = claims[i].owed_fraction; + } + qsort(by_size, n_claims, sizeof *by_size, rank_desc); + qsort(by_owed, n_claims, sizeof *by_owed, rank_desc); + + /* How many slots to hold back. Never all of them: the biggest miners are + * also the ones whose omission wastes the most block, so the reservation + * is a minority of the coinbase by construction. */ + size_t slots = expected_slots > n_claims ? n_claims : expected_slots; + size_t reserved = (slots * PPLNS_RESERVED_SLOT_NUMERATOR) + / PPLNS_RESERVED_SLOT_DENOMINATOR; + if (reserved >= slots && slots > 0) reserved = slots - 1; + + size_t n = 0; + /* The reserved slots first, so they survive the byte budget: it cuts from + * the end of this array, and a slot reserved after the cut is not a slot. + * Only workers actually owed something qualify -- on a pool that has + * always paid everyone this loop places nobody and the order is exactly + * largest-first, as it was before the ledger existed. */ + for (size_t k = 0; k < n_claims && n < reserved; ++k) { + if (by_owed[k].key <= 0.0) break; + order[n++] = by_owed[k].idx; + placed[by_owed[k].idx] = 1; + } + /* Then everyone else, largest claim first. */ + for (size_t k = 0; k < n_claims; ++k) { + size_t i = by_size[k].idx; + if (placed[i]) continue; + order[n++] = i; + placed[i] = 1; + } + + free(by_size); free(by_owed); free(placed); + return n == n_claims ? 0 : -1; +} diff --git a/src/pplns.h b/src/pplns.h index afb988e..35ec681 100644 --- a/src/pplns.h +++ b/src/pplns.h @@ -24,8 +24,51 @@ typedef struct { const char *payout_address; double difficulty; /* this worker's share of the window */ + int64_t worker_id; /* for the fraction ledger; 0 if unknown */ + /* What this worker is owed from previous blocks, as a signed fraction of + * ONE block reward. Positive: skipped before, and first in the queue for + * a slot now. Negative: paid early out of somebody else's skipped share, + * so it waits. Zero across a pool that has always been able to pay + * everyone. See pplns_order_claims(). */ + double owed_fraction; } pplns_claim_t; +/* How many of the coinbase's payout slots are held back for whoever has been + * waiting longest, rather than given to the largest claims. + * + * Not a tuning knob so much as the thing that makes the queue move at all. A + * large miner's share of the current window is bigger than the largest debt a + * small miner can ever accumulate, so ranking by "claim plus what you are + * owed" still hands every slot to the same addresses, every block, for ever. + * Measured on a production coinbase-direct pool: over 31 blocks, 279 payout + * slots reached 34 addresses, 12 of which took 91% of them, while 88 + * addresses were paid nothing — and 28 of those cleared the payout floor + * comfortably, so the floor was not what excluded them + * (LayerTwo-Labs/simplepool#76). + * + * Reserving slots costs no coinbase bytes and changes nobody's total. It + * changes how OFTEN people are paid, not how much. */ +#define PPLNS_RESERVED_SLOT_NUMERATOR 1 +#define PPLNS_RESERVED_SLOT_DENOMINATOR 4 /* a quarter of the slots */ + +/* Order `claims` into the sequence the coinbase should pay them in, writing + * the permutation into `order` (indices into `claims`). + * + * The default is largest claim first, which puts the payout floor and the + * byte budget on the smallest claims — the ones for whom missing a block + * costs least. Then a fraction of the slots the coinbase is expected to have + * room for are handed instead to the workers with the largest positive + * `owed_fraction`, longest-waiting first. + * + * `expected_slots` is how many payouts the caller believes will fit. It only + * decides how many slots are reserved; getting it wrong changes the fairness + * of the rotation, never the arithmetic — everyone in `order` is still paid + * their own claim, and anyone the budget cuts is still redistributed. + * + * Returns 0, or negative on bad input. */ +int pplns_order_claims(const pplns_claim_t *claims, size_t n_claims, + size_t expected_slots, size_t *order); + typedef struct { int64_t fee_sats; /* the operator's cut, off the top */ int64_t payable_sats; /* what the payees must sum to, exactly */ diff --git a/src/reconcile.c b/src/reconcile.c index fd35cb9..e380a71 100644 --- a/src/reconcile.c +++ b/src/reconcile.c @@ -102,6 +102,31 @@ void reconcile_blocks_pass(const reconcile_cfg_t *cfg, int tip_height, * `return` above this without moving it. * * test_reconcile.c pins it from both directions. */ + /* Settle the coinbase-direct fraction ledger on the same pass, and for + * the same reason: this is the only place that knows whether a block is + * still in the chain. A block whose coinbase skipped somebody moves them + * up the queue -- but only if that block actually stood. Orphaned ones + * have their staged rows discarded, because their coinbase paid nobody + * and rotated nobody. + * + * Runs whatever the mode, because a pool switched away from + * pplns-coinbase still has rows to settle or discard from when it was. */ + { + int applied = 0, discarded = 0; + char ferr[256] = {0}; + if (store_settle_block_fractions(cfg->store, &applied, &discarded, + ferr, sizeof ferr) < 0) { + LOG_WARN("pplns-coinbase: could not settle the payout queue: %s — " + "the staged rows stay and the next tip retries", + ferr[0] ? ferr : "unknown"); + } else if (applied || discarded) { + LOG_INFO("pplns-coinbase: payout queue settled for %d confirmed " + "block(s); discarded %d orphaned", applied, discarded); + } + r.fractions_applied = applied; + r.fractions_discarded = discarded; + } + if (cfg->pplns) { int blocks = 0, workers = 0; char derr[256] = {0}; diff --git a/src/reconcile.h b/src/reconcile.h index d09a933..970430e 100644 --- a/src/reconcile.h +++ b/src/reconcile.h @@ -75,6 +75,9 @@ typedef struct { int ran_distribution; /* store_pplns_distribute was called */ int blocks_distributed; int worker_credits; + /* pplns-coinbase payout-queue rows settled this pass. */ + int fractions_applied; + int fractions_discarded; int distribute_failed; /* the call returned an error */ } reconcile_result_t; diff --git a/src/store.c b/src/store.c index 67c8e06..fdd26d8 100644 --- a/src/store.c +++ b/src/store.c @@ -290,6 +290,9 @@ static const char *SCHEMA_SQL_PARTS[] = { ");" "CREATE INDEX IF NOT EXISTS payouts_worker_ts_idx ON payouts(worker_id, paid_at);" "CREATE INDEX IF NOT EXISTS payouts_paid_at_idx ON payouts(paid_at);", + "CREATE TABLE IF NOT EXISTS pplns_fractions ( worker_id INTEGER PRIMARY KEY REFERENCES workers(id), owed_fraction REAL NOT NULL DEFAULT 0, updated_at INTEGER )", + "CREATE TABLE IF NOT EXISTS pplns_pending_fractions ( block_hash TEXT NOT NULL, worker_id INTEGER NOT NULL, delta REAL NOT NULL, PRIMARY KEY (block_hash, worker_id) )", + "CREATE INDEX IF NOT EXISTS pplns_pending_hash_idx ON pplns_pending_fractions(block_hash)", }; /* Forward-compat: ALTER existing DBs to add columns that didn't exist in @@ -1527,9 +1530,11 @@ int store_pplns_window(store_t *s, double window_diff, } static const char *Q = - "SELECT w.id, COALESCE(w.payout_address,''), SUM(sh.difficulty) AS wd " + "SELECT w.id, COALESCE(w.payout_address,''), SUM(sh.difficulty) AS wd, " + " COALESCE(f.owed_fraction, 0.0) " " FROM shares sh " " JOIN workers w ON w.id = sh.worker_id " + " LEFT JOIN pplns_fractions f ON f.worker_id = w.id " " WHERE sh.id >= ? " " AND w.payout_address IS NOT NULL AND w.payout_address <> '' " " GROUP BY w.id " @@ -1553,6 +1558,7 @@ int store_pplns_window(store_t *s, double window_diff, snprintf(out[n].payout_address, sizeof out[n].payout_address, "%s", addr ? (const char *)addr : ""); out[n].difficulty = sqlite3_column_double(st, 2); + out[n].owed_fraction = sqlite3_column_double(st, 3); total += out[n].difficulty; n++; } @@ -1563,6 +1569,158 @@ int store_pplns_window(store_t *s, double window_diff, return (int)n; } +int store_stage_block_fractions(store_t *s, const char *block_hash, + const store_fraction_delta_t *deltas, size_t n, + char *errbuf, size_t errlen) +{ + if (!s || !s->db || !block_hash || !block_hash[0] || (!deltas && n)) { + if (errbuf && errlen) snprintf(errbuf, errlen, "bad arg"); + return -1; + } + if (n == 0) return 0; + + /* The deltas describe a redistribution, so they must cancel. A set that + * does not sum to zero has invented somebody's turn or destroyed it, and + * writing it would put the ledger permanently out of balance -- the one + * invariant that makes "nobody is owed money" checkable. Floating point + * means "zero" is a tolerance, sized well below the smallest rotation + * anyone could notice. */ + double sum = 0.0; + for (size_t i = 0; i < n; ++i) sum += deltas[i].delta; + if (sum > 1e-9 || sum < -1e-9) { + if (errbuf && errlen) + snprintf(errbuf, errlen, + "fraction deltas sum to %g, not zero", sum); + return -1; + } + + static const char *Q = + "INSERT INTO pplns_pending_fractions (block_hash, worker_id, delta) " + "VALUES (?, ?, ?) " + "ON CONFLICT(block_hash, worker_id) DO UPDATE SET delta = excluded.delta"; + + if (sqlite3_exec(s->db, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) { + if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); + return -1; + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2(s->db, Q, -1, &st, NULL) != SQLITE_OK) { + if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); + sqlite3_exec(s->db, "ROLLBACK", NULL, NULL, NULL); + atomic_fetch_add(&s->pg_errors, 1); + return -2; + } + int wrote = 0, ok = 1; + for (size_t i = 0; i < n; ++i) { + if (deltas[i].worker_id <= 0) continue; + sqlite3_reset(st); + sqlite3_bind_text (st, 1, block_hash, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64 (st, 2, (sqlite3_int64)deltas[i].worker_id); + sqlite3_bind_double(st, 3, deltas[i].delta); + if (sqlite3_step(st) != SQLITE_DONE) { ok = 0; break; } + wrote++; + } + sqlite3_finalize(st); + if (!ok) { + if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); + sqlite3_exec(s->db, "ROLLBACK", NULL, NULL, NULL); + atomic_fetch_add(&s->pg_errors, 1); + return -2; + } + sqlite3_exec(s->db, "COMMIT", NULL, NULL, NULL); + return wrote; +} + +int store_settle_block_fractions(store_t *s, int *out_applied, + int *out_discarded, + char *errbuf, size_t errlen) +{ + if (out_applied) *out_applied = 0; + if (out_discarded) *out_discarded = 0; + if (!s || !s->db) { + if (errbuf && errlen) snprintf(errbuf, errlen, "bad arg"); + return -1; + } + + /* One transaction for the whole settlement. A partially applied block + * would leave the ledger not summing to zero, and unlike a failed payout + * there is no later pass that could notice: the pending rows are gone. */ + if (sqlite3_exec(s->db, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) { + if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); + return -1; + } + + /* SQLite folds duplicate worker rows within one INSERT..SELECT rather than + * applying each, so settle one block at a time: two confirmed blocks that + * both moved the same worker must move it twice. */ + static const char *ONE_HASH = + "SELECT DISTINCT p.block_hash, b.status " + " FROM pplns_pending_fractions p " + " JOIN blocks_found b ON b.hash = p.block_hash " + " WHERE b.status IN ('confirmed','orphaned') " + " LIMIT 64"; + + char hashes[64][80]; + int is_conf[64]; + int nh = 0; + sqlite3_stmt *sel = NULL; + if (sqlite3_prepare_v2(s->db, ONE_HASH, -1, &sel, NULL) != SQLITE_OK) { + if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); + sqlite3_exec(s->db, "ROLLBACK", NULL, NULL, NULL); + return -2; + } + while (nh < 64 && sqlite3_step(sel) == SQLITE_ROW) { + const unsigned char *h = sqlite3_column_text(sel, 0); + const unsigned char *st_ = sqlite3_column_text(sel, 1); + if (!h) continue; + snprintf(hashes[nh], sizeof hashes[nh], "%s", (const char *)h); + is_conf[nh] = st_ && strcmp((const char *)st_, "confirmed") == 0; + nh++; + } + sqlite3_finalize(sel); + + static const char *APPLY_ONE = + "INSERT INTO pplns_fractions (worker_id, owed_fraction, updated_at) " + "SELECT p.worker_id, p.delta, strftime('%s','now') " + " FROM pplns_pending_fractions p WHERE p.block_hash = ? " + "ON CONFLICT(worker_id) DO UPDATE SET " + " owed_fraction = pplns_fractions.owed_fraction + excluded.owed_fraction, " + " updated_at = excluded.updated_at"; + static const char *DROP_ONE = + "DELETE FROM pplns_pending_fractions WHERE block_hash = ?"; + + int applied = 0, discarded = 0, ok = 1; + for (int i = 0; i < nh && ok; ++i) { + if (is_conf[i]) { + sqlite3_stmt *a = NULL; + if (sqlite3_prepare_v2(s->db, APPLY_ONE, -1, &a, NULL) != SQLITE_OK) { ok = 0; break; } + sqlite3_bind_text(a, 1, hashes[i], -1, SQLITE_TRANSIENT); + if (sqlite3_step(a) != SQLITE_DONE) ok = 0; + sqlite3_finalize(a); + if (ok) applied++; + } else { + discarded++; + } + if (!ok) break; + sqlite3_stmt *d = NULL; + if (sqlite3_prepare_v2(s->db, DROP_ONE, -1, &d, NULL) != SQLITE_OK) { ok = 0; break; } + sqlite3_bind_text(d, 1, hashes[i], -1, SQLITE_TRANSIENT); + if (sqlite3_step(d) != SQLITE_DONE) ok = 0; + sqlite3_finalize(d); + } + + if (!ok) { + if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); + sqlite3_exec(s->db, "ROLLBACK", NULL, NULL, NULL); + atomic_fetch_add(&s->pg_errors, 1); + return -2; + } + sqlite3_exec(s->db, "COMMIT", NULL, NULL, NULL); + if (out_applied) *out_applied = applied; + if (out_discarded) *out_discarded = discarded; + return 0; +} + 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 739598d..f8d471a 100644 --- a/src/store.h +++ b/src/store.h @@ -175,6 +175,12 @@ typedef struct { int64_t worker_id; char payout_address[128]; double difficulty; /* this worker's difficulty inside the window */ + /* Signed fraction of one block reward this worker is owed from blocks + * whose coinbase had no room for it, or has been overpaid from absorbing + * somebody else's skipped share. Zero on a pool that has always been able + * to pay everyone. See pplns_fractions in schema.sql -- it is a memory of + * whose turn it is, not a balance, and the pool holds nothing against it. */ + double owed_fraction; } store_window_entry_t; /* Fill `out` with the window's payable workers, largest first, and set @@ -221,6 +227,42 @@ int store_record_share_addr(store_t *s, const char *worker_name, /* PPS credit: add delta_sats to the worker's accrued_sats in pps_credits. * Async (writer thread). delta_sats must be > 0. payout_address (the * miner's Thunder address) is tagged onto the workers row as usual. */ +/* One worker's change in standing from a block's coinbase. */ +typedef struct store_fraction_delta { + int64_t worker_id; + double delta; /* + skipped and owed; - paid early and owes back */ +} store_fraction_delta_t; + +/* Stage what a found block's coinbase did to the fraction ledger. + * + * Held against `block_hash` rather than applied, because a found block is a + * CANDIDATE: submitblock may have refused it and the chain may still reorg it + * away, and in either case its coinbase paid nobody. Applying here would + * record a rotation that never happened and move a miner down the queue for a + * payment it never got. + * + * The deltas must sum to zero, which the caller computes and this checks: a + * ledger that does not is one that has invented or destroyed somebody's turn. + * + * Returns the number of rows staged, or negative on error. */ +int store_stage_block_fractions(store_t *s, const char *block_hash, + const store_fraction_delta_t *deltas, size_t n, + char *errbuf, size_t errlen); + +/* Apply the staged deltas for every block that has since been CONFIRMED, and + * discard them for every block that has been ORPHANED. + * + * Called from the confirmation pass, on the same schedule and for the same + * reason as PPLNS distribution: this is the only place that knows whether a + * block is still in the chain. Idempotent -- staged rows are deleted as they + * are applied, so a second pass over the same block does nothing. + * + * *out_applied / *out_discarded receive the block counts (either may be NULL). + * Returns 0 ok, negative on error. */ +int store_settle_block_fractions(store_t *s, int *out_applied, + int *out_discarded, + char *errbuf, size_t errlen); + 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/stratum.c b/src/stratum.c index 406605c..8068644 100644 --- a/src/stratum.c +++ b/src/stratum.c @@ -21,6 +21,7 @@ #define _POSIX_C_SOURCE 200809L #include "stratum.h" #include "coinbase.h" +#include "store.h" #include "share.h" #include "log.h" #include "thunder.h" @@ -143,6 +144,10 @@ struct stratum_job { * allocations rather than one per miner. */ coinbase_payee_t *payees; char *payee_addrs; + /* Who each payee is, so a found block can name who it skipped. An address + * is not enough: two rigs can share one, and the fraction ledger is per + * worker. */ + int64_t *payee_worker_ids; size_t n_payees; uint64_t created_ms; /* for retention ring */ @@ -247,6 +252,7 @@ void stratum_job_free(stratum_job_t *j) { } free(j->payees); free(j->payee_addrs); + free(j->payee_worker_ids); free(j); } @@ -254,25 +260,30 @@ void stratum_job_free(stratum_job_t *j) { * of payees and one arena the addresses live in, so a 200-miner window is not * 200 strdups that have to be unwound on every job retirement. */ int stratum_job_set_window(stratum_job_t *j, - const coinbase_payee_t *payees, size_t n_payees) { + const coinbase_payee_t *payees, + const int64_t *worker_ids, size_t n_payees) { if (!j) return -1; free(j->payees); j->payees = NULL; free(j->payee_addrs); j->payee_addrs = NULL; + free(j->payee_worker_ids); j->payee_worker_ids = NULL; j->n_payees = 0; if (!payees || n_payees == 0) return 0; enum { ADDR_STRIDE = 128 }; coinbase_payee_t *arr = calloc(n_payees, sizeof *arr); char *arena = calloc(n_payees, ADDR_STRIDE); - if (!arr || !arena) { free(arr); free(arena); return -1; } + int64_t *ids = calloc(n_payees, sizeof *ids); + if (!arr || !arena || !ids) { free(arr); free(arena); free(ids); return -1; } for (size_t i = 0; i < n_payees; ++i) { char *dst = arena + i * ADDR_STRIDE; snprintf(dst, ADDR_STRIDE, "%s", payees[i].address ? payees[i].address : ""); arr[i].address = dst; arr[i].sats = payees[i].sats; + ids[i] = worker_ids ? worker_ids[i] : 0; } j->payees = arr; j->payee_addrs = arena; + j->payee_worker_ids = ids; j->n_payees = n_payees; return 0; } @@ -2176,6 +2187,61 @@ static int submit_with_job(stratum_server_t *s, stratum_conn_t *c, cJSON *id, } if (wrc == 0) { coinbase_parts_free(&throwaway); + /* Record who this block skipped, and who absorbed their share. + * + * Expressed as a signed fraction of one block reward: what a + * worker was ENTITLED to out of this block, minus what the + * coinbase actually paid it. Positive means skipped and owed a + * slot; negative means paid early out of somebody else's share. + * The set sums to zero by construction, because redistribution + * moves value between miners and never in or out. + * + * Fractions rather than sats or difficulty, because this is + * consulted blocks later: shares stay in the window across several + * blocks, so rolling unpaid difficulty forward double-counts the + * same work, and difficulty is not comparable across a retarget. + * + * Staged against the block hash, not applied — this block is a + * candidate and its coinbase has paid nobody yet. */ + if (s->cfg.on_window_fractions && job->payee_worker_ids && + res.paid_sats > 0) { + struct store_fraction_delta d[COINBASE_MAX_PAYOUT_OUTPUTS]; + size_t nd = 0; + int64_t entitled_total = 0, survivors_own = 0; + for (size_t i = 0; i < job->n_payees; ++i) { + entitled_total += job->payees[i].sats; + if (i < res.paid_count) survivors_own += job->payees[i].sats; + } + if (entitled_total > 0 && survivors_own > 0) { + /* The builder pays in job order, so the first paid_count + * payees are the ones that got an output. + * + * `got` is a survivor's share of what was actually paid + * out, which after redistribution is the WHOLE payable + * amount -- so it is their claim over the survivors' claims, + * not over the window's. Dividing by res.paid_sats instead + * looks equivalent and is not: redistribution sets that to + * the full payable amount, so every delta came out as + * exactly zero and the queue never recorded anybody. */ + for (size_t i = 0; i < job->n_payees && + nd < COINBASE_MAX_PAYOUT_OUTPUTS; ++i) { + double entitled = (double)job->payees[i].sats / + (double)entitled_total; + double got = i < res.paid_count + ? (double)job->payees[i].sats / + (double)survivors_own + : 0.0; + double delta = entitled - got; + if (delta > 1e-12 || delta < -1e-12) { + d[nd].worker_id = job->payee_worker_ids[i]; + d[nd].delta = delta; + nd++; + } + } + } + if (nd > 0) + s->cfg.on_window_fractions(s->cfg.ctx, block_hash_hex, d, nd); + } size_t dropped = res.dropped_below_floor + res.dropped_capped; if (dropped > 0) { LOG_INFO("pplns-coinbase: block %s paid %zu miner(s) %lld " diff --git a/src/stratum.h b/src/stratum.h index cc13275..193da56 100644 --- a/src/stratum.h +++ b/src/stratum.h @@ -78,7 +78,8 @@ stratum_job_t *stratum_job_new( * * Returns 0 on success, negative on allocation failure. */ int stratum_job_set_window(stratum_job_t *j, - const coinbase_payee_t *payees, size_t n_payees); + const coinbase_payee_t *payees, + const int64_t *worker_ids, size_t n_payees); void stratum_job_free(stratum_job_t *j); @@ -106,6 +107,15 @@ typedef int (*block_submit_fn)(void *ctx, const char *block_hex, * `submit_error` the reason when it was not. A candidate the node refused is * still reported here — it is recorded as 'rejected' rather than dropped, * because a silent reject is how phantom rewards went unnoticed. */ +/* What a found block's coinbase did to each miner's standing in the queue, as + * signed fractions of one block reward that sum to zero. The callback stages + * them against the block hash; the confirmation pass decides whether they ever + * take effect. pplns-coinbase only. */ +struct store_fraction_delta; +typedef void (*window_fractions_fn)(void *ctx, const char *block_hash, + const struct store_fraction_delta *deltas, + size_t n); + typedef void (*block_found_fn)(void *ctx, const char *worker_name, const char *finder_address, @@ -205,7 +215,8 @@ typedef struct { * is a snapshot taken when the template was built. */ int coinbase_pays_window; size_t max_coinbase_bytes; /* 0 = COINBASE_DEFAULT_MAX_BYTES */ - int64_t payout_floor_sats; /* below this a claim is forfeited, not paid */ + int64_t payout_floor_sats; /* below this a claim is not paid this block */ + window_fractions_fn on_window_fractions; /* pplns-coinbase only */ /* 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 diff --git a/tests/test_coinbase.c b/tests/test_coinbase.c index fe6fde2..a331e65 100644 --- a/tests/test_coinbase.c +++ b/tests/test_coinbase.c @@ -994,19 +994,20 @@ static void test_the_payout_floor_is_configurable(void) { printf("ok: the payout floor is configurable and clamped up to dust\n"); } -/* The byte budget is about marketplaces rejecting an oversized coinbase, so - * it has to fall on the smallest claims: paying largest-first means the - * forfeit lands on whoever has least at stake in it. */ -static void test_the_cap_falls_on_the_smallest_claims(void) { +/* The byte budget falls on whoever the CALLER put last. + * + * The builder used to sort largest-first itself, so this was automatic. It no + * longer does: the order is policy and belongs to pplns.c, which supplies + * largest-first by default. This pins the default arrangement — pass them in + * priority order and the smallest claims are the ones that miss out. */ +static void test_the_cap_falls_on_whoever_is_last_in_the_order(void) { coinbase_parts_t parts; char err[256]; coinbase_window_result_t res; + /* Priority order, as pplns_order_claims() produces by default. */ const coinbase_payee_t payees[] = { - { WA, 1000000LL }, { WB, 3000000LL }, { WC, 6000000LL }, + { WC, 6000000LL }, { WB, 3000000LL }, { WA, 1000000LL }, }; - /* fee 1% of 10,101,010 ~ 101,010; make the numbers exact instead. */ int64_t value = 1000000LL + 3000000LL + 6000000LL; /* fee_bps 0: no fee */ - /* The operator address is still required: capping produces a forfeit, - * and a forfeit needs somewhere to go even when there is no fee. */ int rc = coinbase_build_window(800000, value, payees, 3, WOP, 0, NULL, NULL, 4, 8, /* Byte budget admitting exactly two of the @@ -1020,12 +1021,42 @@ static void test_the_cap_falls_on_the_smallest_claims(void) { assert(rc == 0); assert(res.paid_count == 2); assert(res.dropped_capped == 1); - /* The 1,000,000 claim is the one dropped, and its share goes to the two - * that fit — not to the operator. */ + /* The 1,000,000 claim is last in the order, so it is the one dropped — + * and its share goes to the two that fit, not to the operator. */ assert(res.redistributed_sats == 1000000LL); assert(res.paid_sats == 10000000LL); /* the whole payable amount */ coinbase_parts_free(&parts); - printf("ok: the output cap drops the smallest claims first\n"); + printf("ok: the byte budget drops whoever is last in the caller's order\n"); +} + +/* And the order is genuinely the caller's: put a small claim first and it + * keeps its slot while a larger one behind it is cut. + * + * This is the capability the fraction ledger needs. Without it a large miner's + * window share beats any priority a small miner can accumulate, so the same + * addresses take the same slots for ever and a queue of skipped miners never + * moves — measured on a production pool as 12 addresses taking 91%% of 279 + * payout slots over 31 blocks (LayerTwo-Labs/simplepool#76). */ +static void test_the_caller_can_promote_a_small_claim(void) { + coinbase_parts_t parts; char err[256]; + coinbase_window_result_t res; + /* The 1,000,000 claim promoted to the front; the 3,000,000 one is now + * last and should be the one the budget cuts. */ + const coinbase_payee_t payees[] = { + { WA, 1000000LL }, { WC, 6000000LL }, { WB, 3000000LL }, + }; + int64_t value = 10000000LL; + assert(coinbase_build_window(800000, value, payees, 3, WOP, 0, NULL, NULL, + 4, 8, 180, 0, &parts, &res, + err, sizeof err) == 0); + assert(res.paid_count == 2); + assert(res.dropped_capped == 1); + assert(res.redistributed_sats == 3000000LL); /* WB was cut, not WA */ + assert(res.paid_sats == value); + /* And the rounding remainder still lands on the largest claim PAID, which + * is no longer the first element. */ + coinbase_parts_free(&parts); + printf("ok: a promoted small claim keeps its slot over a larger one\n"); } /* A window that cannot be paid in full no longer needs an operator address at @@ -1416,7 +1447,8 @@ int main(void) { test_a_payee_below_the_floor_is_shared_out_not_given_to_the_operator(); test_the_operator_cannot_profit_by_shrinking_the_coinbase(); test_the_payout_floor_is_configurable(); - test_the_cap_falls_on_the_smallest_claims(); + test_the_cap_falls_on_whoever_is_last_in_the_order(); + test_the_caller_can_promote_a_small_claim(); test_a_dropped_claim_needs_no_operator_address(); test_no_operator_address_means_no_fee(); test_a_window_of_only_dust_is_refused(); diff --git a/tests/test_pplns.c b/tests/test_pplns.c index fed8ba3..6d58df8 100644 --- a/tests/test_pplns.c +++ b/tests/test_pplns.c @@ -42,7 +42,7 @@ static int64_t sum_payees(const coinbase_payee_t *p, size_t n) { * satoshi or the builder is right to refuse it. */ static void test_the_split_always_spends_the_whole_block(void) { const pplns_claim_t claims[] = { - { A, 700.0 }, { B, 200.0 }, { C, 99.0 }, { D, 1.0 }, + { A, 700.0, 0, 0.0 }, { B, 200.0, 0, 0.0 }, { C, 99.0, 0, 0.0 }, { D, 1.0, 0, 0.0 }, }; coinbase_payee_t out[4]; pplns_split_t r; @@ -58,7 +58,7 @@ static void test_the_split_always_spends_the_whole_block(void) { /* Proportional to difficulty, and in the order the store hands them over. */ static void test_each_claim_gets_its_difficulty_share(void) { - const pplns_claim_t claims[] = { { A, 750.0 }, { B, 250.0 } }; + const pplns_claim_t claims[] = { { A, 750.0, 0, 0.0 }, { B, 250.0, 0, 0.0 } }; coinbase_payee_t out[2]; pplns_split_t r; char err[256] = {0}; @@ -79,7 +79,7 @@ static void test_the_rounding_remainder_goes_to_the_largest_claim(void) { /* Three equal claims of a reward that does NOT divide by three. * 100,000,002 does, which is how the first draft of this test passed * while asserting the wrong thing. */ - const pplns_claim_t claims[] = { { A, 1.0 }, { B, 1.0 }, { C, 1.0 } }; + const pplns_claim_t claims[] = { { A, 1.0, 0, 0.0 }, { B, 1.0, 0, 0.0 }, { C, 1.0, 0, 0.0 } }; coinbase_payee_t out[3]; pplns_split_t r; char err[256] = {0}; @@ -103,8 +103,8 @@ static void test_a_mixed_window_predicts_who_the_floor_will_drop(void) { * The tail claims are 1 part in 10 million: 312,500,000 * 1e-7 = 31 sats, * comfortably under the 546-sat dust floor. */ const pplns_claim_t claims[] = { - { A, 6000000.0 }, { B, 3999997.0 }, - { C, 2.0 }, { D, 1.0 }, + { A, 6000000.0, 0, 0.0 }, { B, 3999997.0, 0, 0.0 }, + { C, 2.0, 0, 0.0 }, { D, 1.0, 0, 0.0 }, }; coinbase_payee_t out[4]; pplns_split_t r; @@ -134,7 +134,7 @@ static void test_a_mixed_window_predicts_who_the_floor_will_drop(void) { * small a miner they will serve, so it has to mean what it says. */ static void test_raising_the_floor_drops_more_claims(void) { const pplns_claim_t claims[] = { - { A, 50.0 }, { B, 30.0 }, { C, 15.0 }, { D, 5.0 }, + { A, 50.0, 0, 0.0 }, { B, 30.0, 0, 0.0 }, { C, 15.0, 0, 0.0 }, { D, 5.0, 0, 0.0 }, }; coinbase_payee_t out[4]; pplns_split_t r; @@ -161,7 +161,7 @@ static void test_raising_the_floor_drops_more_claims(void) { * clamps it. If these two disagreed the pool would warn about one number and * pay by another, which is worse than not warning at all. */ static void test_the_floor_prediction_uses_the_builders_clamp(void) { - const pplns_claim_t claims[] = { { A, 999.0 }, { B, 1.0 } }; + const pplns_claim_t claims[] = { { A, 999.0, 0, 0.0 }, { B, 1.0, 0, 0.0 } }; coinbase_payee_t out[2]; pplns_split_t r; char err[256] = {0}; @@ -187,14 +187,14 @@ static void test_the_floor_and_dust_boundaries_are_exact(void) { /* A claim worth EXACTLY the floor clears it. 546 parts in 1,000,000 of * 1,000,000 sats is 546 sats on the nose. */ - const pplns_claim_t at_floor[] = { { A, 999454.0 }, { B, 546.0 } }; + const pplns_claim_t at_floor[] = { { A, 999454.0, 0, 0.0 }, { B, 546.0, 0, 0.0 } }; CHECK(pplns_split_window(1000000LL, 0, 0, at_floor, 2, 1000000.0, 546, out, 2, &r, err, sizeof err) == 0); CHECK(out[1].sats == 546LL); CHECK(r.below_floor == 0); /* exactly at the floor is PAID */ /* One satoshi under it is not. */ - const pplns_claim_t under[] = { { A, 999455.0 }, { B, 545.0 } }; + const pplns_claim_t under[] = { { A, 999455.0, 0, 0.0 }, { B, 545.0, 0, 0.0 } }; CHECK(pplns_split_window(1000000LL, 0, 0, under, 2, 1000000.0, 546, out, 2, &r, err, sizeof err) == 0); CHECK(out[1].sats == 545LL); @@ -202,7 +202,7 @@ static void test_the_floor_and_dust_boundaries_are_exact(void) { /* The fee's dust boundary, the same way. 1% of 54,600 is 546 exactly, * which is payable; 1% of 54,500 is 545, which is dust and dropped. */ - const pplns_claim_t one[] = { { A, 1.0 } }; + const pplns_claim_t one[] = { { A, 1.0, 0, 0.0 } }; CHECK(pplns_split_window(54600LL, 100, 1, one, 1, 1.0, 546, out, 1, &r, err, sizeof err) == 0); CHECK(r.fee_sats == 546LL); @@ -227,7 +227,7 @@ static void test_the_floor_and_dust_boundaries_are_exact(void) { * refuse a split that does not sum to reward-minus-fee, so a disagreement * here means no coinbase renders at all. */ static void test_the_fee_matches_what_the_builder_will_expect(void) { - const pplns_claim_t claims[] = { { A, 1.0 } }; + const pplns_claim_t claims[] = { { A, 1.0, 0, 0.0 } }; coinbase_payee_t out[1]; pplns_split_t r; char err[256] = {0}; @@ -270,7 +270,7 @@ static void test_the_builder_accepts_what_the_splitter_produces(void) { }; static const int FEES[] = { 0, 1, 100, 250, 1000 }; const pplns_claim_t claims[] = { - { A, 7.0 }, { B, 3.0 }, { C, 1.0 }, + { A, 7.0, 0, 0.0 }, { B, 3.0, 0, 0.0 }, { C, 1.0, 0, 0.0 }, }; for (size_t i = 0; i < sizeof REWARDS / sizeof REWARDS[0]; ++i) { for (size_t j = 0; j < sizeof FEES / sizeof FEES[0]; ++j) { @@ -306,7 +306,7 @@ static void test_the_builder_accepts_what_the_splitter_produces(void) { * Refuse rather than hand the builder a split it will reject on every * connection. */ static void test_a_window_total_that_is_too_small_is_refused(void) { - const pplns_claim_t claims[] = { { A, 60.0 }, { B, 60.0 } }; + const pplns_claim_t claims[] = { { A, 60.0, 0, 0.0 }, { B, 60.0, 0, 0.0 } }; coinbase_payee_t out[2]; char err[256] = {0}; CHECK(pplns_split_window(100000000LL, 0, 0, claims, 2, 100.0, @@ -316,7 +316,7 @@ static void test_a_window_total_that_is_too_small_is_refused(void) { } static void test_the_degenerate_inputs_are_refused(void) { - const pplns_claim_t claims[] = { { A, 1.0 } }; + const pplns_claim_t claims[] = { { A, 1.0, 0, 0.0 } }; coinbase_payee_t out[2]; char err[256] = {0}; CHECK(pplns_split_window(1000, 0, 0, NULL, 1, 1.0, 546, out, 2, NULL, err, sizeof err) < 0); @@ -328,6 +328,106 @@ static void test_the_degenerate_inputs_are_refused(void) { printf("ok: degenerate inputs are refused, not divided\n"); } +/* ---- payment order ------------------------------------------------------ */ + +/* With nobody owed anything, the order is exactly what it was before the + * ledger existed: largest claim first, so the floor and the byte budget fall + * on the smallest. A pool that has always been able to pay everyone must not + * behave differently for having gained a ledger it never uses. */ +static void test_with_nothing_owed_the_order_is_largest_first(void) { + pplns_claim_t c[5]; + double sizes[] = { 10, 50, 30, 5, 20 }; + for (int i = 0; i < 5; ++i) { + c[i].payout_address = A; c[i].worker_id = i + 1; + c[i].difficulty = sizes[i]; c[i].owed_fraction = 0.0; + } + size_t order[5]; + CHECK(pplns_order_claims(c, 5, 5, order) == 0); + CHECK(c[order[0]].difficulty == 50); + CHECK(c[order[1]].difficulty == 30); + CHECK(c[order[2]].difficulty == 20); + CHECK(c[order[3]].difficulty == 10); + CHECK(c[order[4]].difficulty == 5); + printf("ok: with nothing owed, the order is largest-first\n"); +} + +/* THE FAILURE THIS EXISTS FOR. + * + * Ranking by "claim plus what you are owed" does not move the queue: a large + * miner's share of the current window is bigger than the largest debt a small + * miner can ever build, so the same addresses take the same slots for ever. + * Reserving slots outright is what fixes it, and this asserts that a + * long-waiting small miner reaches a slot even when every large miner in the + * window outweighs it many times over. */ +static void test_a_long_waiting_small_miner_reaches_a_slot(void) { + enum { N = 20, SLOTS = 4 }; + pplns_claim_t c[N]; + for (int i = 0; i < N; ++i) { + c[i].payout_address = A; c[i].worker_id = i + 1; + c[i].difficulty = 1000.0 / (i + 1); /* i=0 is by far the largest */ + c[i].owed_fraction = 0.0; + } + /* The smallest miner in the window, owed a little from being skipped. Its + * claim is 1/20th of the largest; no additive ranking would ever promote + * it. */ + c[N - 1].owed_fraction = 0.004; + + size_t order[N]; + CHECK(pplns_order_claims(c, N, SLOTS, order) == 0); + /* One slot of four is reserved, and it goes to the waiting miner. */ + CHECK(order[0] == N - 1); + /* The rest of the slots still go to the largest claims, in order, so the + * bulk of the block is not handed to the tail. */ + CHECK(order[1] == 0); + CHECK(order[2] == 1); + CHECK(order[3] == 2); + printf("ok: a long-waiting small miner reaches a reserved slot\n"); +} + +/* Reserved slots are a minority of the coinbase, always. The biggest claims + * are also the ones whose omission wastes the most block, so a rotation that + * could take every slot would be worse than the problem it solves. */ +static void test_the_reservation_never_takes_every_slot(void) { + enum { N = 8 }; + pplns_claim_t c[N]; + for (int i = 0; i < N; ++i) { + c[i].payout_address = A; c[i].worker_id = i + 1; + c[i].difficulty = 100.0 - i; + c[i].owed_fraction = 1.0; /* everyone is owed something */ + } + size_t order[N]; + for (size_t slots = 1; slots <= N; ++slots) { + CHECK(pplns_order_claims(c, N, slots, order) == 0); + /* Every claim appears exactly once, whatever the reservation did. */ + int seen[N] = {0}; + for (size_t i = 0; i < N; ++i) { CHECK(order[i] < N); seen[order[i]]++; } + for (size_t i = 0; i < N; ++i) CHECK(seen[i] == 1); + /* And at least one slot is still decided by claim size. */ + size_t reserved = (slots * PPLNS_RESERVED_SLOT_NUMERATOR) + / PPLNS_RESERVED_SLOT_DENOMINATOR; + CHECK(reserved < slots); + } + printf("ok: the reservation never takes every slot\n"); +} + +/* A negative balance means "paid early, out of someone else's skipped share", + * so it waits rather than jumping the queue. Only positive balances qualify + * for a reserved slot. */ +static void test_being_paid_early_does_not_win_a_reserved_slot(void) { + enum { N = 6 }; + pplns_claim_t c[N]; + for (int i = 0; i < N; ++i) { + c[i].payout_address = A; c[i].worker_id = i + 1; + c[i].difficulty = 10.0 * (N - i); + c[i].owed_fraction = -0.5; /* everyone has been paid early */ + } + size_t order[N]; + CHECK(pplns_order_claims(c, N, 4, order) == 0); + /* Nobody is owed, so nothing is reserved and it is pure largest-first. */ + for (size_t i = 0; i < N; ++i) CHECK(order[i] == i); + printf("ok: a negative balance does not win a reserved slot\n"); +} + /* Randomised conservation check. * * The hand-written cases above pin splits somebody chose; this asserts the @@ -347,6 +447,8 @@ static void test_conservation_holds_for_random_windows(void) { * proportional split loses satoshis if it is going to. */ double d = (double)(rand() % 1000000) / (double)(1 + rand() % 1000); claims[i].payout_address = A; + claims[i].worker_id = (int64_t)i + 1; + claims[i].owed_fraction = 0.0; claims[i].difficulty = d; total += d; } @@ -395,6 +497,10 @@ int main(void) { test_the_builder_accepts_what_the_splitter_produces(); test_a_window_total_that_is_too_small_is_refused(); test_the_degenerate_inputs_are_refused(); + test_with_nothing_owed_the_order_is_largest_first(); + test_a_long_waiting_small_miner_reaches_a_slot(); + test_the_reservation_never_takes_every_slot(); + test_being_paid_early_does_not_win_a_reserved_slot(); test_conservation_holds_for_random_windows(); if (failures) { printf("test_pplns: %d FAILED\n", failures); return 1; } printf("test_pplns: all tests passed\n"); diff --git a/tests/test_pplns_coinbase_regtest.sh b/tests/test_pplns_coinbase_regtest.sh index a842ef1..d84dc5c 100755 --- a/tests/test_pplns_coinbase_regtest.sh +++ b/tests/test_pplns_coinbase_regtest.sh @@ -30,9 +30,12 @@ # forfeited to the operator and never settled, which is a trap unless # the operator can see it — so the disclosure lines are asserted here # exactly like the money is. -# 6. a MIXED window really does forfeit, on chain. Claims of 100 : 10 : 1, -# a floor between the last two: the first two are paid in the coinbase, -# the third gets no output, and its satoshis turn up on the operator's. +# 6. a MIXED window really does redistribute, on chain. Claims of +# 100 : 10 : 1 with a floor between the last two: the first two are paid +# in the coinbase, the third gets no output, and its satoshis turn up +# spread across the first two -- NOT on the operator's output, which +# holds its fee to the satoshi. The block also records whose turn was +# skipped, in a queue that sums to zero. # # That last stage is the one this file could not do for a long time, and the # reason is worth writing down. Share difficulty is clamped to network @@ -503,10 +506,11 @@ if small in paid: f"{paid[small]} anyway", file=sys.stderr) sys.exit(1) -# And its money went to the operator, not nowhere. The operator must hold -# strictly more than the 1% fee, and the block must still be spent whole: -# a forfeit that vanished would show up as a coinbase paying out less than -# it may, which is value destroyed rather than merely redirected. +# And its money went to THE OTHER MINERS, not to the operator. This is the +# assertion that changed in #76: it used to require the operator to hold more +# than its fee, which is exactly the behaviour that turned out to hand the +# house a quarter of the block. Now the operator holds its fee to the satoshi +# and the miners who fit divide everything else. total = sum(paid.values()) if total != 5000000000: print(f"FAIL: the coinbase pays {total}, not the whole 50 BTC block", @@ -514,36 +518,81 @@ if total != 5000000000: sys.exit(1) fee_only = 50000000 op_sats = paid.get(op, 0) -if op_sats <= fee_only: - print(f"FAIL: operator holds {op_sats}, no more than the {fee_only}-sat " - f"fee — the forfeited claim went nowhere", file=sys.stderr) +if op_sats != fee_only: + print(f"FAIL: operator holds {op_sats}, expected exactly the {fee_only}-sat " + f"fee — a dropped claim leaked to the house", file=sys.stderr) sys.exit(1) -forfeited = op_sats - fee_only -print(f" forfeited to the operator: {forfeited} sats " - f"(on top of the {fee_only}-sat fee)") -# Roughly 1/111 of the payable amount. Bounded rather than exact because the -# block finder's own share may or may not have entered the window before the -# job was built; either way the SMALL claim is the one that lost. -if not (40000000 <= forfeited <= 50000000): - print(f"FAIL: forfeited {forfeited} sats, expected ~44.6M " + +# The two who were paid must have received MORE than their own claims: they +# absorbed the third. Their own shares of the 4,950,000,000 payable amount are +# 100/111 and 10/111, so anything at or below those means nothing was +# redistributed and the money was simply destroyed. +own_big = 4950000000 * 100 // 111 +own_mid = 4950000000 * 10 // 111 +if paid[big] <= own_big or paid[mid] <= own_mid: + print(f"FAIL: BIG {paid[big]} (own share {own_big}) and MID {paid[mid]} " + f"(own {own_mid}) — the skipped claim was not redistributed", + file=sys.stderr) + sys.exit(1) +absorbed = (paid[big] - own_big) + (paid[mid] - own_mid) +print(f" redistributed to the miners who fit: {absorbed} sats") +print(f" BIG {own_big} -> {paid[big]}") +print(f" MID {own_mid} -> {paid[mid]}") +# Roughly SMALL's 1-in-111 share of the payable amount. +if not (40000000 <= absorbed <= 50000000): + print(f"FAIL: {absorbed} sats redistributed, expected ~44.6M " f"(1 share in 111 of the payable amount)", file=sys.stderr) sys.exit(1) PY # And the pool reported it, with the numbers, so an operator answering "why -# was I not paid?" has something to answer from. -grep -q "were forfeited to the operator" "$MIX_LOG" || { - echo "FAIL: the block paid a forfeit but the pool never reported one" >&2 +# was I not paid this block?" has something to answer from. +grep -q "REDISTRIBUTED across the miners" "$MIX_LOG" || { + echo "FAIL: the block redistributed a claim but the pool never reported it" >&2 grep -i "pplns-coinbase: block" "$MIX_LOG" | tail -3 >&2; exit 1; } -echo " reported: $(grep -o '[0-9]* claim(s) worth [0-9]* sats were forfeited' "$MIX_LOG" | tail -1)" +echo " reported: $(grep -o '[0-9]* claim(s) worth [0-9]* sats had no room' "$MIX_LOG" | tail -1)" -# Still no ledger. A forfeit is income, not a debt -- if this mode ever grew a -# carry it would show up here first. +# Still no BALANCE ledger. The payout queue below is a memory of whose turn it +# is, not money owed; pps_credits must stay empty regardless. MIX_ROWS="$(sqlite3 "$MIX_DB" "SELECT COUNT(*) FROM pps_credits")" [ "$MIX_ROWS" = "0" ] || { - echo "FAIL: a forfeit created $MIX_ROWS ledger row(s); it must create none" >&2 + echo "FAIL: a redistribution created $MIX_ROWS credit row(s); it must create none" >&2 + exit 1; } +echo " pps_credits rows=0 — redistributed, not owed" + +stage "assert the payout queue recorded who was skipped, and balances" +# The block skipped SMALL, so somebody's standing must have moved -- and the +# whole queue must sum to zero, which is the invariant that makes "nobody is +# owed money" a checkable claim rather than a promise. +QROWS="$(sqlite3 "$MIX_DB" "SELECT COUNT(*) FROM pplns_pending_fractions")" +FROWS="$(sqlite3 "$MIX_DB" "SELECT COUNT(*) FROM pplns_fractions")" +echo " staged=$QROWS applied=$FROWS" +[ "$QROWS" -ge 1 ] || [ "$FROWS" -ge 1 ] || { + echo "FAIL: a block skipped a miner but nothing was recorded in the queue" >&2 + exit 1; } + +# Zero-sum, across both the staged rows and any already applied. Rounded to +# 1e-6 of a block, far below anything that could matter. +BAL="$(sqlite3 "$MIX_DB" "SELECT CAST(ROUND(( + COALESCE((SELECT SUM(delta) FROM pplns_pending_fractions),0) + + COALESCE((SELECT SUM(owed_fraction) FROM pplns_fractions),0)) * 1000000) AS INT)")" +[ "$BAL" = "0" ] || { + echo "FAIL: the payout queue sums to $BAL (x1e-6), not zero — somebody's" >&2 + echo " turn has been invented or destroyed" >&2 + sqlite3 "$MIX_DB" "SELECT 'pending', worker_id, delta FROM pplns_pending_fractions + UNION ALL SELECT 'applied', worker_id, owed_fraction FROM pplns_fractions" >&2 + exit 1; } +echo " the payout queue sums to zero — nobody is owed money, only a turn" + +# And the skipped miner is the one owed, not the ones that were paid. +SKIPPED_ID="$(sqlite3 "$MIX_DB" "SELECT id FROM workers WHERE payout_address='$SMALL'")" +OWED="$(sqlite3 "$MIX_DB" "SELECT CAST(ROUND(COALESCE(( + SELECT SUM(delta) FROM pplns_pending_fractions WHERE worker_id=$SKIPPED_ID),0) * 1000) AS INT)")" +[ "${OWED:-0}" -gt 0 ] || { + echo "FAIL: the skipped miner (worker $SKIPPED_ID) is not owed a turn" >&2 + sqlite3 "$MIX_DB" "SELECT worker_id, delta FROM pplns_pending_fractions" >&2 exit 1; } -echo " pps_credits rows=0 — forfeited, not carried" +echo " the skipped miner is owed $OWED/1000 of a block reward, and is next in line" echo echo "cbwin-e2e: PASS (the window was paid from the block's own coinbase," diff --git a/tests/test_store.c b/tests/test_store.c index f887b75..32cb9a3 100644 --- a/tests/test_store.c +++ b/tests/test_store.c @@ -1560,6 +1560,161 @@ static void test_the_window_reads_past_the_first_batch(void) { printf(" ok test_the_window_reads_past_the_first_batch\n"); } +/* ---- the pplns-coinbase payout queue ------------------------------------ + * + * A signed fraction of one block reward per worker: positive means skipped and + * owed a slot, negative means paid early out of somebody else's skipped share. + * It is a memory of whose turn it is, NOT a balance — the pool holds no money + * against it, and deleting the table would cost nobody a payment. + * + * The invariant that makes that claim checkable is that it sums to zero. These + * tests exist for it, and for the orphan case, which is the one that can + * silently move a miner down the queue for a payment it never received. */ +static void test_fraction_deltas_must_sum_to_zero(void) { + const char *path = fresh_db_path(); + store_cfg_t cfg = {0}; + snprintf(cfg.path, sizeof(cfg.path), "%s", path); + store_t *s = NULL; + assert(store_open(&cfg, &s) == 0); + char err[256] = {0}; + + /* Balanced: one miner skipped, one paid early by the same amount. */ + store_fraction_delta_t ok_[] = { {1, 0.25}, {2, -0.25} }; + assert(store_stage_block_fractions(s, "aa", ok_, 2, err, sizeof err) == 2); + + /* Unbalanced: this would invent a turn out of nothing. */ + store_fraction_delta_t bad[] = { {1, 0.25}, {2, -0.10} }; + assert(store_stage_block_fractions(s, "bb", bad, 2, err, sizeof err) < 0); + assert(strstr(err, "sum to") != NULL); + + store_close(s); + printf(" ok test_fraction_deltas_must_sum_to_zero\n"); +} + +/* Staged rows do nothing until the block they came from is CONFIRMED — and + * are thrown away if it is orphaned. A block that never stood paid nobody and + * rotated nobody. */ +static void test_only_a_confirmed_block_moves_the_queue(void) { + const char *path = fresh_db_path(); + store_cfg_t cfg = {0}; + snprintf(cfg.path, sizeof(cfg.path), "%s", path); + store_t *s = NULL; + assert(store_open(&cfg, &s) == 0); + char err[256] = {0}; + + sqlite3 *db = NULL; + assert(sqlite3_open(path, &db) == SQLITE_OK); + assert(sqlite3_exec(db, + "INSERT INTO workers (id,name,payout_address,first_seen,last_seen)" + " VALUES (1,'a','bc1qa',1,1),(2,'b','bc1qb',1,1)", + NULL, NULL, NULL) == SQLITE_OK); + assert(sqlite3_exec(db, + "INSERT INTO blocks_found (ts,height,hash,reward_sats,fee_sats,status)" + " VALUES (1,10,'good',100,1,'pending'),(1,11,'bad',100,1,'pending')", + NULL, NULL, NULL) == SQLITE_OK); + + store_fraction_delta_t d1[] = { {1, 0.25}, {2, -0.25} }; + store_fraction_delta_t d2[] = { {1, 0.50}, {2, -0.50} }; + assert(store_stage_block_fractions(s, "good", d1, 2, err, sizeof err) == 2); + assert(store_stage_block_fractions(s, "bad", d2, 2, err, sizeof err) == 2); + + /* Both blocks are still pending: nothing has moved. */ + int applied = -1, discarded = -1; + assert(store_settle_block_fractions(s, &applied, &discarded, err, sizeof err) == 0); + assert(applied == 0 && discarded == 0); + assert(scalar_i64(db, "SELECT COUNT(*) FROM pplns_fractions") == 0); + + /* One confirms, one is orphaned. */ + assert(sqlite3_exec(db, "UPDATE blocks_found SET status='confirmed' WHERE hash='good'", + NULL, NULL, NULL) == SQLITE_OK); + assert(sqlite3_exec(db, "UPDATE blocks_found SET status='orphaned' WHERE hash='bad'", + NULL, NULL, NULL) == SQLITE_OK); + assert(store_settle_block_fractions(s, &applied, &discarded, err, sizeof err) == 0); + assert(applied == 1); + assert(discarded == 1); + + /* Only the confirmed block's rotation took effect... */ + char buf[64]; + scalar_text(db, "SELECT CAST(ROUND(owed_fraction*100) AS INT) " + "FROM pplns_fractions WHERE worker_id=1", buf, sizeof buf); + assert(strcmp(buf, "25") == 0); /* 0.25, not 0.75 */ + /* ...and the ledger still sums to zero. */ + scalar_text(db, "SELECT CAST(ROUND(SUM(owed_fraction)*1000) AS INT) " + "FROM pplns_fractions", buf, sizeof buf); + assert(strcmp(buf, "0") == 0); + /* Nothing is left staged, so a second pass is a no-op. */ + assert(scalar_i64(db, "SELECT COUNT(*) FROM pplns_pending_fractions") == 0); + assert(store_settle_block_fractions(s, &applied, &discarded, err, sizeof err) == 0); + assert(applied == 0 && discarded == 0); + + sqlite3_close(db); + store_close(s); + printf(" ok test_only_a_confirmed_block_moves_the_queue\n"); +} + +/* Two confirmed blocks that both moved the same worker must move it twice. + * Settling them in one statement would fold the rows together and lose one. */ +static void test_two_confirmed_blocks_both_count(void) { + const char *path = fresh_db_path(); + store_cfg_t cfg = {0}; + snprintf(cfg.path, sizeof(cfg.path), "%s", path); + store_t *s = NULL; + assert(store_open(&cfg, &s) == 0); + char err[256] = {0}; + + sqlite3 *db = NULL; + assert(sqlite3_open(path, &db) == SQLITE_OK); + sqlite3_exec(db, "INSERT INTO workers (id,name,payout_address,first_seen,last_seen)" + " VALUES (1,'a','bc1qa',1,1),(2,'b','bc1qb',1,1)", NULL, NULL, NULL); + sqlite3_exec(db, "INSERT INTO blocks_found (ts,height,hash,reward_sats,fee_sats,status)" + " VALUES (1,10,'h1',100,1,'confirmed'),(1,11,'h2',100,1,'confirmed')", + NULL, NULL, NULL); + store_fraction_delta_t d[] = { {1, 0.25}, {2, -0.25} }; + assert(store_stage_block_fractions(s, "h1", d, 2, err, sizeof err) == 2); + assert(store_stage_block_fractions(s, "h2", d, 2, err, sizeof err) == 2); + + int applied = 0, discarded = 0; + assert(store_settle_block_fractions(s, &applied, &discarded, err, sizeof err) == 0); + assert(applied == 2); + char buf[64]; + scalar_text(db, "SELECT CAST(ROUND(owed_fraction*100) AS INT) " + "FROM pplns_fractions WHERE worker_id=1", buf, sizeof buf); + assert(strcmp(buf, "50") == 0); /* 0.25 twice, not folded to 0.25 */ + sqlite3_close(db); + store_close(s); + printf(" ok test_two_confirmed_blocks_both_count\n"); +} + +/* The window hands the ledger standing back with each claim, so the ordering + * policy can see it. Zero for a worker that has never been skipped. */ +static void test_the_window_reports_each_workers_standing(void) { + const char *path = fresh_db_path(); + store_cfg_t cfg = {0}; + snprintf(cfg.path, sizeof(cfg.path), "%s", path); + store_t *s = NULL; + assert(store_open(&cfg, &s) == 0); + + sqlite3 *db = NULL; + assert(sqlite3_open(path, &db) == SQLITE_OK); + sqlite3_exec(db, "INSERT INTO workers (id,name,payout_address,first_seen,last_seen)" + " VALUES (1,'a','bc1qa',1,1),(2,'b','bc1qb',1,1)", NULL, NULL, NULL); + sqlite3_exec(db, "INSERT INTO shares (worker_id,ts,difficulty) VALUES" + " (1,1,10.0),(2,1,5.0)", NULL, NULL, NULL); + sqlite3_exec(db, "INSERT INTO pplns_fractions (worker_id,owed_fraction,updated_at)" + " VALUES (2,0.4,1)", NULL, NULL, NULL); + sqlite3_close(db); + + store_window_entry_t win[4]; + size_t n = 0; double total = 0; int tr = 0; char err[256]; + assert(store_pplns_window(s, 100.0, win, 4, &n, &total, &tr, err, sizeof err) == 2); + /* Largest claim first, as always. */ + assert(win[0].worker_id == 1 && win[0].owed_fraction == 0.0); + assert(win[1].worker_id == 2); + assert(win[1].owed_fraction > 0.39 && win[1].owed_fraction < 0.41); + store_close(s); + printf(" ok test_the_window_reports_each_workers_standing\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(); @@ -1615,6 +1770,10 @@ int main(void) { test_pplns_takes_the_operator_fee(); test_the_payout_floor_is_published_for_the_dashboard(); test_the_window_reads_past_the_first_batch(); + test_fraction_deltas_must_sum_to_zero(); + test_only_a_confirmed_block_moves_the_queue(); + test_two_confirmed_blocks_both_count(); + test_the_window_reports_each_workers_standing(); test_pplns_distributes_two_blocks_in_one_pass(); test_an_empty_window_returns_nothing_not_an_error(); test_a_window_wider_than_the_cap_says_so(); diff --git a/tests/test_stratum.c b/tests/test_stratum.c index 5228767..0b42173 100644 --- a/tests/test_stratum.c +++ b/tests/test_stratum.c @@ -2759,7 +2759,7 @@ static void test_pplns_coinbase_pays_every_miner_in_the_window(void) { { TEST_ADDR, 3000000000LL }, { TEST_ADDR2, 2000000000LL }, }; - CHECK(stratum_job_set_window(job, win, 2) == 0); + CHECK(stratum_job_set_window(job, win, NULL, 2) == 0); stratum_server_set_job(s, job, 1); stratum_conn_t *c = stratum_conn_new_for_test(s); From 9c1f556ef32dd62f02e1b79b66e414f4791b18ac Mon Sep 17 00:00:00 2001 From: rob Date: Wed, 9 Sep 2026 08:46:00 +0200 Subject: [PATCH 20/36] pplns-coinbase: make the coinbase byte ceiling per-listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wired4ncer's fourth point, and the last one outstanding. The ceiling that actually binds is a MARKETPLACE rule — whoever rents you hashrate verifies the coinbase and refuses a job it considers oversized — and it applies to the port they connect to and nowhere else. Every byte of it costs a payout: measured on this branch, a 100-miner window pays 9 at a 400-byte ceiling and 93 at 3000. Applying a rental market's limit to your own miners' port therefore buys nothing and costs them their slots. `max_coinbase_bytes` is now settable on a `listener` line, overriding the server-wide value; 0 or absent means "use the server-wide one", the same convention the other per-listener fields already use. The precedent is exact — `min_diff` exists on a listener for the same reason, with the same comment about a marketplace measuring what the port advertises. coinbase_max_bytes = 3000 listener = port=3335 label=rental min_diff=500000 initial_diff=500000 max_coinbase_bytes=900 In pplns-coinbase this changes how many of the window a given port is served, not the window itself: the payees and their priority order come off the job, and each port takes as many of them as it can fit. A test drives two connections against ONE job and gets 20 payouts on the home port against 12 on the rented one. One correctness detail worth naming. The coinbase is rendered in one place and re-derived in another — to work out what a found block actually paid, for the payout queue — and those two using different ceilings would record a rotation that never happened. Both now go through a single conn_coinbase_budget() accessor, so they cannot disagree. A listener ceiling too small to hold one payout is refused at config load, the same as the server-wide setting: a port that can pay nobody is not a port. --- INSTALL.md | 5 ++- README.md | 7 +++ docs/simplepool.html | 5 ++- proxy.conf.example | 13 +++++- src/config.c | 11 +++++ src/stratum.c | 38 +++++++++++++--- src/stratum.h | 12 +++++ tests/test_config.c | 33 ++++++++++++++ tests/test_stratum.c | 102 +++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 217 insertions(+), 9 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 26348f6..e92b986 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -565,7 +565,10 @@ rather than the pool: - `coinbase_max_bytes` budgets the **whole serialized coinbase**, commitments included — that is what a rented-hashrate marketplace measures when it refuses a job as oversized. On a drivechain the BIP300/301 `OP_RETURN`s - spend it before any payout does. + spend it before any payout does. Settable per listener + (`listener = port=3335 … max_coinbase_bytes=900`), which is usually what you + want: the ceiling only applies to the port rented hashrate connects to, and + every byte of it costs a payout. - `pplns_payout_floor_sats` is the least a claim must be worth to get an output at all. diff --git a/README.md b/README.md index d67dd84..d9b2d7e 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,13 @@ and what a stratum username is: to 16 miners, where the same 16 payouts cost 817 bytes against four drivechain `OP_RETURN`s and 769 against three. A cap counted in outputs cannot see that; a byte budget can. + + It is settable **per listener**, and usually should be. The ceiling is a + marketplace rule enforced on the port the rented hashrate connects to, and + every byte of it costs a payout — a 100-miner window pays 9 at 400 bytes + and 93 at 3000 — so there is no reason to make your own miners live under a + limit their port is never measured against. Set it tight on the rental + listener and leave the rest alone. - `pplns_payout_floor_sats` (default 546, the dust limit) is the minimum a claim must be worth to get an output at all. diff --git a/docs/simplepool.html b/docs/simplepool.html index b5f75af..f603594 100644 --- a/docs/simplepool.html +++ b/docs/simplepool.html @@ -2179,7 +2179,10 @@

    Configuration

    coinbase_max_bytes1000 pplns-coinbase only. Byte budget for the WHOLE serialized coinbase, commitments included — a rented-hashrate marketplace measures bytes, and drivechain - OP_RETURNs spend them before any payout does. Minimum 200. + OP_RETURNs spend them before any payout does. Minimum 200. Settable + per listener (max_coinbase_bytes= on a listener line), + which is usually right: the ceiling binds only on the port rented hashrate + connects to, and every byte of it costs a payout. pplns_payout_floor_sats546 pplns-coinbase only. A claim worth less than this is not paid, and is forfeited to the operator — nothing is carried and nothing settles diff --git a/proxy.conf.example b/proxy.conf.example index 8c163bb..9683b2c 100644 --- a/proxy.conf.example +++ b/proxy.conf.example @@ -228,8 +228,19 @@ pool_mode = solo # cannot see that; this can, because the commitments are simply part of what # has already been spent. # -# Whatever does not fit is forfeited to the operator — see +# Whatever does not fit is redistributed across the miners it could pay — see # pplns_payout_floor_sats below. +# +# Settable PER LISTENER, and usually should be. The ceiling that actually binds +# is a marketplace rule enforced on the port rented hashrate connects to, and +# every byte of it costs a payout: at 400 bytes a 100-miner window pays 9, at +# 3000 it pays 93. There is no reason to make your own miners live under a +# limit their port is not measured against: +# +# coinbase_max_bytes = 3000 +# listener = port=3335 label=rental min_diff=500000 initial_diff=500000 max_coinbase_bytes=900 +# +# A listener that sets none uses the server-wide value below. # coinbase_max_bytes = 1000 # pplns-coinbase — the payout floor, in satoshis. A miner whose share of a diff --git a/src/config.c b/src/config.c index ce16031..b18b61f 100644 --- a/src/config.c +++ b/src/config.c @@ -158,12 +158,23 @@ static int parse_listener(const char *v, stratum_listener_t *out, else if (strcmp(fk, "min_diff") == 0) min_diff = atof(fv); else if (strcmp(fk, "initial_diff") == 0) initial = atof(fv); else if (strcmp(fk, "max_diff") == 0) out->vardiff_max = atof(fv); + else if (strcmp(fk, "max_coinbase_bytes") == 0) out->max_coinbase_bytes = atoi(fv); else if (strcmp(fk, "label") == 0) copy_str(out->label, sizeof out->label, fv); else { set_err(errbuf, errlen, "unknown listener field '%s'", fk); return -1; } } + /* Same floor as the server-wide setting: below this no coinbase can hold + * even one payout, so the port could not pay anybody at all. 0 means "use + * the server-wide one" and is always fine. */ + if (out->max_coinbase_bytes != 0 && out->max_coinbase_bytes < 200) { + set_err(errbuf, errlen, + "listener max_coinbase_bytes = %d is too small to hold a " + "coinbase and a single payout; omit it to use the server-wide " + "coinbase_max_bytes", out->max_coinbase_bytes); + return -1; + } if (out->port <= 0 || out->port > 65535) { set_err(errbuf, errlen, "listener needs a port between 1 and 65535"); return -1; diff --git a/src/stratum.c b/src/stratum.c index 8068644..d1fc4b1 100644 --- a/src/stratum.c +++ b/src/stratum.c @@ -372,6 +372,9 @@ struct stratum_conn { * its hashrate is split. */ double requested_min_diff; + /* This connection's coinbase byte ceiling, from its listener; 0 means the + * server-wide one. See stratum_listener_t.max_coinbase_bytes. */ + int pol_max_coinbase_bytes; /* Difficulty policy inherited from the listener this connection was * accepted on, resolved once at accept time so nothing downstream has to * know which port it came in on. Seeded from the server-wide defaults, @@ -774,6 +777,20 @@ static int j_payees_missing(const stratum_job_t *job) { return !job->payees || job->n_payees == 0; } +/* The byte ceiling that applies to THIS connection: its listener's, or the + * server-wide one when the listener did not set its own. + * + * One accessor rather than two lookups, because the coinbase is rendered in + * one place and re-derived in another (to work out what a found block paid), + * and those two disagreeing would mean the payout queue recorded a rotation + * that did not happen. */ +static size_t conn_coinbase_budget(const stratum_server_t *s, + const stratum_conn_t *c) { + if (c && c->pol_max_coinbase_bytes > 0) + return (size_t)c->pol_max_coinbase_bytes; + return s->cfg.max_coinbase_bytes; +} + static int conn_render_coinbase(stratum_server_t *s, stratum_conn_t *c, const stratum_job_t *job) { if (!c->authorized || c->payout_address[0] == '\0') return -1; @@ -805,7 +822,7 @@ static int conn_render_coinbase(stratum_server_t *s, stratum_conn_t *c, job->coinbasetxn_hex, job->payees, job->n_payees, s->cfg.operator_address, s->cfg.fee_bps, s->cfg.coinbase_tag, job->en1_size, job->en2_size, - s->cfg.max_coinbase_bytes, s->cfg.payout_floor_sats, + conn_coinbase_budget(s, c), s->cfg.payout_floor_sats, &parts, NULL, NULL, err, sizeof err); } else { @@ -813,7 +830,7 @@ static int conn_render_coinbase(stratum_server_t *s, stratum_conn_t *c, job->height, job->value_sats, job->payees, job->n_payees, s->cfg.operator_address, s->cfg.fee_bps, job->wc_hex, s->cfg.coinbase_tag, job->en1_size, job->en2_size, - s->cfg.max_coinbase_bytes, s->cfg.payout_floor_sats, + conn_coinbase_budget(s, c), s->cfg.payout_floor_sats, &parts, NULL, err, sizeof err); } } else if (s->cfg.coinbase_pays_pool) { @@ -2175,14 +2192,14 @@ static int submit_with_job(stratum_server_t *s, stratum_conn_t *c, cJSON *id, job->coinbasetxn_hex, job->payees, job->n_payees, s->cfg.operator_address, s->cfg.fee_bps, s->cfg.coinbase_tag, job->en1_size, job->en2_size, - s->cfg.max_coinbase_bytes, s->cfg.payout_floor_sats, + conn_coinbase_budget(s, c), s->cfg.payout_floor_sats, &throwaway, NULL, &res, werr, sizeof werr); } else { wrc = coinbase_build_window( job->height, job->value_sats, job->payees, job->n_payees, s->cfg.operator_address, s->cfg.fee_bps, job->wc_hex, s->cfg.coinbase_tag, job->en1_size, job->en2_size, - s->cfg.max_coinbase_bytes, s->cfg.payout_floor_sats, + conn_coinbase_budget(s, c), s->cfg.payout_floor_sats, &throwaway, &res, werr, sizeof werr); } if (wrc == 0) { @@ -2257,8 +2274,8 @@ static int submit_with_job(stratum_server_t *s, stratum_conn_t *c, cJSON *id, res.dropped_below_floor, (long long)s->cfg.payout_floor_sats, res.dropped_capped, - s->cfg.max_coinbase_bytes - ? s->cfg.max_coinbase_bytes + conn_coinbase_budget(s, c) + ? conn_coinbase_budget(s, c) : (size_t)COINBASE_DEFAULT_MAX_BYTES); } else { LOG_INFO("pplns-coinbase: block %s paid all %zu miner(s) in " @@ -2437,6 +2454,7 @@ static void conn_apply_listener(stratum_conn_t *c, if (pol->vardiff_min > 0.0) c->pol_vardiff_min = pol->vardiff_min; if (pol->vardiff_max > 0.0) c->pol_vardiff_max = pol->vardiff_max; c->pol_min_diff = pol->min_diff; /* 0 unless the port promised one */ + c->pol_max_coinbase_bytes = pol->max_coinbase_bytes; /* 0 = server-wide */ c->pol_port = pol->port; snprintf(c->pol_label, sizeof c->pol_label, "%s", pol->label); /* Before authorize the connection has no assigned difficulty yet, so @@ -2450,6 +2468,14 @@ void stratum_conn_apply_listener_for_test(stratum_conn_t *c, conn_apply_listener(c, pol); } +/* Put a test connection on a listener's policy, the way accept() does for a + * real one. Only the coinbase ceiling is exposed: it is the one piece of + * listener policy that changes what a block PAYS rather than how hard the + * work is, so it is the one a test has to be able to drive. */ +void stratum_conn_set_coinbase_budget_for_test(stratum_conn_t *c, int bytes) { + if (c) c->pol_max_coinbase_bytes = bytes; +} + stratum_conn_t *stratum_conn_new_for_test(stratum_server_t *s) { stratum_conn_t *c = calloc(1, sizeof(*c)); if (!c) return NULL; diff --git a/src/stratum.h b/src/stratum.h index 193da56..bd55c19 100644 --- a/src/stratum.h +++ b/src/stratum.h @@ -153,6 +153,17 @@ typedef struct { * for one, which is how the default port and every low-difficulty chain * keep their existing behaviour. See clamp_assigned_difficulty. */ double min_diff; + /* This port's coinbase byte ceiling, overriding the server-wide + * coinbase_max_bytes. 0 means "use the server-wide one". + * + * Here for the same reason min_diff is: the ceiling that actually binds is + * a MARKETPLACE rule, enforced by whoever is renting you hashrate, and it + * only applies to the port they connect to. A byte ceiling costs payouts — + * every miner it cuts is one the block cannot pay — so applying a + * rental market's limit to your own miners' port buys nothing and costs + * them their slots (LayerTwo-Labs/simplepool#76). Set it tight on the + * rented port and leave it alone everywhere else. */ + int max_coinbase_bytes; /* Free-form, for logs and for the dashboard to tell miners which port to * point which machine at. Empty for the default listener. */ char label[32]; @@ -322,6 +333,7 @@ typedef struct stratum_conn stratum_conn_t; /* Allocate a connection state attached to a server. Used by tests; the * real listener uses an internal allocator. */ stratum_conn_t *stratum_conn_new_for_test(stratum_server_t *s); +void stratum_conn_set_coinbase_budget_for_test(stratum_conn_t *c, int bytes); void stratum_conn_free_for_test(stratum_conn_t *c); /* Test accessors — connection internals are otherwise opaque. */ diff --git a/tests/test_config.c b/tests/test_config.c index f5ad5cc..50fea1d 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -271,6 +271,38 @@ static void test_the_coinbase_budget_defaults_and_parses(void) { CHECK(cfg.coinbase_max_bytes == 820); } +/* A per-listener coinbase ceiling. + * + * The ceiling that actually binds is a marketplace rule, enforced by whoever + * rents you hashrate, and it applies only to the port they connect to. Since + * every byte of ceiling costs a payout, imposing a rental market's limit on + * your own miners' port cuts their slots for nothing. */ +static void test_a_listener_can_carry_its_own_coinbase_ceiling(void) { + proxy_config_t cfg; char err[256] = {0}; + char body[640]; + snprintf(body, sizeof body, + "operator_address = %s\npool_mode = pplns-coinbase\n" + "coinbase_max_bytes = 4000\n" + "listener = port=3335 label=rental min_diff=1000 initial_diff=1000 max_coinbase_bytes=900\n" + "listener = port=3336 label=home\n", VALID_ADDR); + CHECK(load_text(body, &cfg, err, sizeof err) == 0); + CHECK(cfg.coinbase_max_bytes == 4000); + CHECK(cfg.listener_count == 2); + /* The rented port takes the tight ceiling... */ + CHECK(cfg.listeners[0].max_coinbase_bytes == 900); + /* ...and a port that did not ask for one stays at 0, which means "use the + * server-wide setting" rather than "no payouts". */ + CHECK(cfg.listeners[1].max_coinbase_bytes == 0); + + /* Too small to hold one payout is refused, exactly as the server-wide + * setting is — a port that can pay nobody is not a port. */ + snprintf(body, sizeof body, + "operator_address = %s\npool_mode = pplns-coinbase\n" + "listener = port=3335 max_coinbase_bytes=150\n", VALID_ADDR); + CHECK(load_text(body, &cfg, err, sizeof err) != 0); + CHECK(strstr(err, "max_coinbase_bytes") != NULL); +} + /* The payout floor decides who this pool refuses to serve, so it has to parse * exactly and default to something an operator can defend. It is the harshest * knob in the file: above it a miner is paid out of the block, below it a @@ -396,6 +428,7 @@ int main(void) { test_rejects_bad_operator_address(); test_the_coinbase_budget_defaults_and_parses(); test_the_payout_floor_defaults_and_parses(); + test_a_listener_can_carry_its_own_coinbase_ceiling(); test_a_tiny_coinbase_budget_is_refused(); test_pplns_coinbase_validates_the_window(); test_pplns_coinbase_refuses_a_pool_wallet(); diff --git a/tests/test_stratum.c b/tests/test_stratum.c index 0b42173..a4805fb 100644 --- a/tests/test_stratum.c +++ b/tests/test_stratum.c @@ -2719,6 +2719,22 @@ static void test_suggest_difficulty_before_authorize(void) { * taken when the template was built. Every connection therefore renders the * same coinbase -- the outputs live in cb2 and only the extranonce differs -- * which is the same shape the pooled modes already have. */ +static stratum_server_t *cbwin_server_budget(stratum_cfg_t *cfg, obs_t *obs, + size_t max_coinbase_bytes) { + *cfg = (stratum_cfg_t){ .bind_port = 0, .max_conns = 4, .initial_diff = 1.0, + .coinbase_pays_window = 1, + .username_is_thunder = 0, + .pps_accrues = 0, + .max_coinbase_bytes = max_coinbase_bytes, + .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->operator_address, sizeof cfg->operator_address, "%s", TEST_ADDR); + stratum_server_t *s = NULL; + stratum_server_start(cfg, &s); + return s; +} + static stratum_server_t *cbwin_server(stratum_cfg_t *cfg, obs_t *obs) { *cfg = (stratum_cfg_t){ .bind_port = 0, .max_conns = 2, .initial_diff = 1.0, .coinbase_pays_window = 1, @@ -2782,6 +2798,90 @@ static void test_pplns_coinbase_pays_every_miner_in_the_window(void) { stratum_server_free(s); } +/* Two ports, one job, different numbers of payouts. + * + * The byte ceiling that actually binds is a marketplace rule: whoever rents + * you hashrate verifies the coinbase and refuses a job it considers oversized. + * It applies to the port they connect to and nowhere else — and since every + * byte of ceiling costs a payout, imposing it on your own miners' port cuts + * their slots for nothing. A pool measured 9 miners paid at a 400-byte ceiling + * against 93 at 3000 (LayerTwo-Labs/simplepool#76). + * + * So the ceiling is per-listener, and the same job renders a different number + * of outputs depending on which port asked. The window itself is unchanged: + * the payees and their order come off the job, and each port simply takes as + * many of them as it can fit. */ +static void test_a_listener_ceiling_changes_how_many_the_coinbase_pays(void) { + obs_t obs = {0}; + stratum_cfg_t cfg; + stratum_server_t *s = cbwin_server_budget(&cfg, &obs, 3000); /* generous */ + CHECK(s != NULL); if (!s) return; + + uint8_t net[32]; memset(net, 0xff, 32); + stratum_job_t *job = make_test_job("JCAP", net); + enum { N = 20 }; + coinbase_payee_t win[N]; + int64_t each = 5000000000LL / N, tot = 0; + for (int i = 0; i < N; ++i) { + win[i].address = (i % 2) ? TEST_ADDR : TEST_ADDR2; + win[i].sats = each; tot += each; + } + win[0].sats += 5000000000LL - tot; + CHECK(stratum_job_set_window(job, win, NULL, N) == 0); + stratum_server_set_job(s, job, 1); + + /* A miner on the default port gets the server-wide ceiling. */ + stratum_conn_t *home = stratum_conn_new_for_test(s); + handshake(s, home); + uint64_t n_home = cbwin_output_count(s, home, "JCAP"); + + /* A miner on the rented port gets that port's tighter one, and is served + * strictly fewer payouts from the very same job. */ + stratum_conn_t *rented = stratum_conn_new_for_test(s); + stratum_conn_set_coinbase_budget_for_test(rented, 500); + handshake(s, rented); + uint64_t n_rent = cbwin_output_count(s, rented, "JCAP"); + + CHECK(n_home == N); /* 3000 bytes fits the whole window */ + CHECK(n_rent > 0); + CHECK(n_rent < n_home); /* and 500 does not */ + + /* Both are still valid coinbases paying out the whole block — the tighter + * one redistributes across the fewer miners it could fit rather than + * dropping the difference. */ + stratum_conn_free_for_test(home); + stratum_conn_free_for_test(rented); + stratum_server_free(s); + printf("ok: a listener's coinbase ceiling changes how many it pays " + "(%llu vs %llu)\n", + (unsigned long long)n_home, (unsigned long long)n_rent); +} + +/* A listener that sets no ceiling of its own uses the server-wide one, rather + * than reading 0 as "no payouts at all". */ +static void test_a_listener_without_a_ceiling_uses_the_server_wide_one(void) { + obs_t obs = {0}; + stratum_cfg_t cfg; + stratum_server_t *s = cbwin_server_budget(&cfg, &obs, 3000); + CHECK(s != NULL); if (!s) return; + + uint8_t net[32]; memset(net, 0xff, 32); + stratum_job_t *job = make_test_job("JDEF", net); + const coinbase_payee_t win[] = { + { TEST_ADDR, 3000000000LL }, { TEST_ADDR2, 2000000000LL }, + }; + CHECK(stratum_job_set_window(job, win, NULL, 2) == 0); + stratum_server_set_job(s, job, 1); + + stratum_conn_t *c = stratum_conn_new_for_test(s); + stratum_conn_set_coinbase_budget_for_test(c, 0); /* explicitly unset */ + handshake(s, c); + CHECK(cbwin_output_count(s, c, "JDEF") == 2); + stratum_conn_free_for_test(c); + stratum_server_free(s); + printf("ok: a listener with no ceiling of its own uses the server-wide one\n"); +} + /* Bootstrap. A pool that has never been mined has no shares, so no window — * and refusing to render there would deadlock it forever: no coinbase means * no miner can work, which means no share, which means no window. @@ -2945,6 +3045,8 @@ int main(void) { test_gate_can_be_disabled(); test_solo_is_never_gated(); test_a_windowless_job_pays_the_finder(); + test_a_listener_ceiling_changes_how_many_the_coinbase_pays(); + test_a_listener_without_a_ceiling_uses_the_server_wide_one(); test_pplns_coinbase_pays_every_miner_in_the_window(); test_pplns_btc_takes_a_bitcoin_username(); test_pplns_thunder_takes_a_thunder_username(); From 532ac5242f56514db22c3ee565eaaa7c608dd34d Mon Sep 17 00:00:00 2001 From: rob Date: Wed, 9 Sep 2026 09:00:05 +0200 Subject: [PATCH 21/36] docs: the forfeit reversal left every document describing a policy the code no longer has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redistribution and the payout queue landed in the code and the docs kept saying the opposite. Nine files claimed a dropped claim is "forfeited to the operator, permanently, not carried, not settled later" — which was true for about a day and is now exactly backwards. Left alone it would have been worse than no documentation: an operator reading it would have advertised a policy their pool does not run, and a miner reading it would have been told their money goes to the house when it goes to the other miners. Corrected in README, INSTALL, VERIFY, OPERATOR_GUIDE, docs/simplepool.html, proxy.conf.example, and the dashboard/payout/tests READMEs — plus the two places it actually reaches people: - the miner-facing dashboard card, which is the one a miner reads before pointing a rig anywhere. It now says a single block may not pay everyone, that what it cannot pay is shared among the miners it could, that the operator takes only its fee, and that being small costs frequency rather than money. Its tests were asserting the old wording, so they asserted the new promise instead, including a doesNotMatch on "goes to the operator". - the pplns-coinbase sequence diagram in docs/simplepool.html, which had "FORFEITED to the operator" drawn into the SVG. Regenerated: it now shows the ordering step, the staging of who was skipped, and that an orphaned block rotates nobody. Rendered and looked at, not just re-emitted. Also documented for the first time, since none of it existed when these pages were last written: the payout queue itself (a signed fraction of a block reward per worker, summing to zero, holding no money), the reserved slots that make it work, and the per-listener coinbase ceiling. VERIFY's section 13 gained the check that matters most — the operator output must be EXACTLY fee_bps of the block, on every block, including ones that could not pay the whole window. That is the one an operator can run to prove the 25% bug is gone. --- INSTALL.md | 22 ++-- OPERATOR_GUIDE.md | 6 +- README.md | 58 +++++++--- VERIFY.md | 40 +++++-- dashboard/README.md | 5 +- .../test/pplns-coinbase-disclosure.test.js | 34 ++++-- dashboard/views/partial/about-numbers.ejs | 45 ++++---- docs/simplepool.html | 103 +++++++++--------- payout/README.md | 5 +- tests/README.md | 5 +- 10 files changed, 198 insertions(+), 125 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index e92b986..cc6ef35 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -572,14 +572,20 @@ rather than the pool: - `pplns_payout_floor_sats` is the least a claim must be worth to get an output at all. -**A claim that clears neither is forfeited to the operator — not carried, not -recorded, not settled later.** That is deliberate: there is nowhere to hold it -because the payment *is* the block. The consequence is a hashrate floor — -a miner too small to clear it will mine here, submit valid shares and earn -nothing indefinitely. The proxy states the floor at startup, warns per -template how many miners fall below it, reports per block what was forfeited, -and publishes the number so the dashboard states it to miners before they -connect. **Publish it on your pool page as well.** See +**A claim that clears neither is paid to the other miners in the window, not +to the operator.** The block still pays out to the satoshi, the pool still +holds nothing, and the operator still takes only its fee. + +Being small costs your miners **frequency, not money**. A quarter of every +coinbase's payout slots are reserved for whoever has waited longest, tracked in +`pplns_fractions` as a signed fraction of one block reward per worker that sums +to zero. It is not a balance and you hold nothing against it — delete the table +and nobody is owed a payment, the pool just forgets whose turn it was. + +The proxy states the floor at startup, warns per template how many miners fall +below it, reports per block what was redistributed, and publishes the number so +the dashboard states it to miners before they connect. **Publish it on your +pool page as well.** See [the five modes](README.md#the-five-modes) and [`VERIFY.md` section 13](VERIFY.md). diff --git a/OPERATOR_GUIDE.md b/OPERATOR_GUIDE.md index 1e1eba4..f6501fe 100644 --- a/OPERATOR_GUIDE.md +++ b/OPERATOR_GUIDE.md @@ -18,8 +18,10 @@ design looks like this). > on maturity out of a block actually found rather than a reserve, so there > is no reserve to size or top up. > - `pplns-coinbase` additionally has a **payout floor**: a claim worth less -> than `pplns_payout_floor_sats` is forfeited to the operator and never -> settled. That is a policy you have to publish to your miners, not just a +> than `pplns_payout_floor_sats` gets no output in that block. What it was +> owed is shared among the miners the block could pay — never you — and the +> skipped miner goes first in the queue for the next block. You take your fee +> and nothing else. That is a policy to publish to your miners, not just a > setting. [`VERIFY.md` section 13](VERIFY.md) is its operational checklist. --- diff --git a/README.md b/README.md index d9b2d7e..e4710f9 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ and what a stratum username is: BIP300/301 commitment `OP_RETURN`s are preserved byte-for-byte — only the enforcer's own reward output is replaced, by the window. - **Two limits, and both cost miners money rather than the pool:** + **Two limits decide how many miners one block can pay:** - `coinbase_max_bytes` (default 1000) budgets the *whole serialized coinbase*, commitments included, because that is what a rented-hashrate @@ -184,22 +184,44 @@ and what a stratum username is: - `pplns_payout_floor_sats` (default 546, the dust limit) is the minimum a claim must be worth to get an output at all. - **A claim that clears neither is forfeited to the operator. It is not - carried, not recorded, and not settled later.** That is a deliberate - policy and not a rounding artefact: there is nowhere to hold it, because - the payment *is* the block, and carrying it would rebuild exactly the - custodial ledger this mode exists to delete. The consequence is a hashrate - floor — a miner too small to clear it will mine here, submit valid shares, - and earn nothing indefinitely, which is strictly worse for them than solo - mining, where they at least hold a lottery ticket. - - Because that is a trap unless it is visible, the floor is disclosed in four - places: the proxy states it at startup, logs how many miners in the current - window fall below it, and reports per block how many claims were forfeited - and for how much — and it publishes the number to `pool_meta`, so the - **dashboard states it to miners before they connect**. That last one is the - one that matters: the operator's log is the one place the miner it costs - cannot look. + **A claim that clears neither is paid to the other miners in the window, + not to the operator.** The block still pays out to the satoshi, the pool + still holds nothing, and the operator still takes only its fee. + + > This was the other way round until [#76][pr76]. A dropped claim used to + > ride on the operator's output, defended as a dust policy. Measurement + > killed it: with 100 miners on a 1/n hashrate spread and the default + > budget, 28 were paid, **72 were cut by the byte cap and none by the dust + > floor**, and the operator received **25% of the block on a 1% fee**. The + > take also rose as the coinbase shrank — 46% at 400 bytes against 2% at + > 3000 — so starving your own miners was the revenue-maximising move. + > Credit to [@Wired4ncer][pr76], who runs the pool that showed it. + + **Being small costs you frequency, not money.** A miner's share of the + window tracks its hashrate, so without help the largest claims would take + the same slots every block and the same addresses would never be paid at + all. A quarter of each coinbase's slots are therefore reserved for whoever + has waited longest, tracked in `pplns_fractions`: a signed fraction of one + block reward per worker, positive if you were skipped and negative if you + were paid early out of someone else's skipped share. The column sums to + zero. + + That is **not a balance and the pool holds nothing against it**. Nothing is + ever withheld from a coinbase and released later — that would need a block + paying less than the reward followed by one paying more, and the second is + invalid. Delete the table and nobody is owed a payment; the pool just + forgets whose turn it was. Rows are staged when a block is found and applied + only once it is confirmed, so an orphaned block — which paid nobody — + rotates nobody. + + The floor is disclosed in four places: the proxy states it at startup, logs + how many miners in the current window fall below it, reports per block what + was redistributed and to whom — and publishes the number to `pool_meta`, so + the **dashboard states it to miners before they connect**. That last one is + the one that matters: the operator's log is the one place the miner it + affects cannot look. + +[pr76]: https://github.com/LayerTwo-Labs/simplepool/pull/76 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: @@ -665,7 +687,7 @@ mode, each mining a real chain: | `tests/test_e2e_regtest.sh` | `pps-classic`: the coinbase pays the pool, and shares accrue at the derived rate | | `tests/test_pplns_regtest.sh` | both pooled PPLNS rails distribute a matured block exactly once | | `tests/test_pplns_btc_payout_regtest.sh` | `pplns-btc` pays miners on L1 through the enforcer wallet | -| `tests/test_pplns_coinbase_regtest.sh` | `pplns-coinbase`: the block's coinbase pays the window, the pool holds nothing, the payout floor is disclosed, and a mixed 100 : 10 : 1 window really does forfeit the smallest claim to the operator on chain | +| `tests/test_pplns_coinbase_regtest.sh` | `pplns-coinbase`: the block's coinbase pays the window, the pool holds nothing, the payout floor is disclosed, and a mixed 100 : 10 : 1 window really does redistribute the smallest claim across the miners that fit — on chain, with the operator holding only its fee and the payout queue summing to zero | | `tests/test_payout_regtest.sh` | the Thunder payout rail settles and confirms | All of them run in CI. For the verification checklist behind each mode, see diff --git a/VERIFY.md b/VERIFY.md index 6c3fe33..2d4b5cf 100644 --- a/VERIFY.md +++ b/VERIFY.md @@ -498,8 +498,9 @@ has to agree with, not just a config to fill in. ### 13.2 · The floor is disclosed, four ways -This is the whole justification for forfeiting rather than carrying, so check -it rather than assume it. +A block cannot pay everyone in a large window, so miners have to know both +halves: what one block may not pay them, and what happens to it. Check this +rather than assume it. - [ ] **Startup**, beside the identity line: *"payout floor N sats — a miner whose share of a block is worth less than that is NOT PAID…"*. It prints @@ -508,10 +509,11 @@ it rather than assume it. miner(s) in the window are below the …-sat payout floor and will earn NOTHING from the next block"*. Only re-logged when the count changes. - [ ] **Per block**: either *"paid all N miner(s)"* or *"N claim(s) worth X - sats were forfeited to the operator"*. + sats had no room and were REDISTRIBUTED across the miners who did fit"*. - [ ] **The dashboard**, before anyone connects. Open `/` and read the "About the numbers" card: it must state the floor in sats and say the - amount is *not carried forward and not paid later*. If it does not, the + amount is *shared out among the miners that block could pay*, and that + the miner goes *first in the queue* for the next one. If it does not, the proxy is on a build that predates `pool_meta.pplns_payout_floor_sats` — the card stays silent rather than inventing a default, so check `sqlite3 shares.db "SELECT pplns_payout_floor_sats FROM pool_meta"`. @@ -528,16 +530,36 @@ homework. `bitcoin-cli getblock 2 | jq '.tx[0].vout'`: There is no pool wallet, so a third address means something is wrong. - [ ] The outputs sum to the whole block reward. A coinbase paying out less than it may destroys the difference. -- [ ] The operator output is `fee + forfeits`, so it is **larger than - `fee_bps` alone** on any block that forfeited. That is the forfeit - arriving, and it is the one number that proves it went somewhere rather - than nowhere. +- [ ] The operator output is **exactly `fee_bps` of the block, and no more**, + on every block — including ones that could not pay the whole window. + This is the check that matters most: until #76 a dropped claim rode on + the operator's output, and on a 100-miner window that came to 25% of the + block against a 1% advertised fee. +- [ ] The miners who *were* paid received **more than their own window + share**, and the outputs still sum to the whole block. That is the + redistribution arriving — if the total is short, value was destroyed + rather than shared. - [ ] `sqlite3 shares.db "SELECT COUNT(*) FROM pps_credits"` is **0**. This - mode writes no ledger row, ever. Any row means a pooled mode's accrual + mode credits no balance, ever. Any row means a pooled mode's accrual path ran. +- [ ] The payout queue balances: + `sqlite3 shares.db "SELECT ROUND(COALESCE((SELECT SUM(delta) FROM + pplns_pending_fractions),0) + COALESCE((SELECT SUM(owed_fraction) FROM + pplns_fractions),0), 9)"` is **0**. It is a record of whose turn it is, + not money — a non-zero sum means somebody's turn was invented or + destroyed. +- [ ] After a block is found but before it confirms, its rows are in + `pplns_pending_fractions` and **not** in `pplns_fractions`. An orphaned + block paid nobody and must rotate nobody; the confirmation pass is what + applies them. ### 13.4 · The byte budget +- [ ] On a rented port, set `max_coinbase_bytes=` on that **listener** rather + than server-wide. The ceiling is a marketplace rule that binds only on + the port the rented hashrate connects to, and every byte of it costs a + payout — a 100-miner window pays 9 at 400 bytes and 93 at 3000. + - [ ] Measure a real coinbase: `bitcoin-cli getblock 2 | jq -r '.tx[0].hex' | wc -c` ÷ 2 = bytes. Compare against `coinbase_max_bytes`. diff --git a/dashboard/README.md b/dashboard/README.md index de2292f..bf9993d 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -125,8 +125,9 @@ do the one thing that cannot work. **`pplns-coinbase` gets one more thing the others do not: the payout floor.** That mode does not pay a claim worth less than `pplns_payout_floor_sats` — it -forfeits it to the operator, permanently, with no ledger entry and no later -settlement. The card states the number before anyone connects, because the +shares it out among the miners that block could pay — never the operator, who +takes only its fee — and puts the skipped miner first in the queue for the next +block. The card states the number before anyone connects, because the operator's log is the one place the miner it costs cannot look. It renders only when the proxy published a floor (`pool_meta.pplns_payout_floor_sats`); an older proxy stores NULL, and printing a default there would be stating some diff --git a/dashboard/test/pplns-coinbase-disclosure.test.js b/dashboard/test/pplns-coinbase-disclosure.test.js index 89542b1..50e7bf3 100644 --- a/dashboard/test/pplns-coinbase-disclosure.test.js +++ b/dashboard/test/pplns-coinbase-disclosure.test.js @@ -62,14 +62,30 @@ test('the payout floor is stated to the miner, in sats', async () => { assert.doesNotMatch(html, /25,000\.00 sats/); }); -test('the floor is described as forfeited, never as carried', async () => { +test('a skipped claim is described as shared out, never as the operator\'s', async () => { const html = await about(makeDb()); - /* The exact claim a miner has to come away with. Softening any of these - * into "held" or "later" would describe the design we deliberately did - * NOT build, and would be a false promise rather than a vague one. */ - assert.match(html, /not.{0,30}carried forward/is); - assert.match(html, /goes to the operator/i); - assert.match(html, /earn nothing/i); + /* The exact claim a miner has to come away with, and it is the opposite of + * what this test asserted before #76: what a block cannot pay goes to the + * OTHER MINERS, and the operator still takes only its fee. */ + assert.match(html, /shared out\s*among the miners/is); + assert.match(html, /takes only its fee/i); + /* And that the cost is frequency, not amount — the sentence a small miner + * needs in order to decide whether to point a rig here. */ + assert.match(html, /less often/i); + assert.match(html, /first in the queue/i); + /* The page must NOT tell miners their share goes to the operator, which + * is what it used to say and is now simply false. */ + assert.doesNotMatch(html, /amount goes to the operator/i); + assert.doesNotMatch(html, /forfeit/i); +}); + +test('the queue is described as an order, not a balance', async () => { + /* The property that makes it defensible: no money is held. A miner who + * reads this must not come away believing the pool owes them a payout + * they could one day claim. */ + const html = await about(makeDb()); + assert.match(html, /no balance to withdraw/i); + assert.match(html, /nobody would be short a payment/i); }); test('a proxy that never published a floor claims none', async () => { @@ -79,7 +95,7 @@ test('a proxy that never published a floor claims none', async () => { const db = makeDb(); db.prepare('UPDATE pool_meta SET pplns_payout_floor_sats = NULL').run(); const html = await about(db); - assert.doesNotMatch(html, /There is a minimum/i); + assert.doesNotMatch(html, /may not pay everyone/i); assert.doesNotMatch(html, /546/); /* But the mode itself is still described -- silence about the floor must * not become silence about the mode. */ @@ -91,7 +107,7 @@ test('a zero floor is still a floor, and still disclosed', async () => { * distinct from NULL. A `|| null` normalisation would collapse the two * and silently stop disclosing. */ const html = await about(makeDb({ floor: 0 })); - assert.match(html, /There is a minimum/i); + assert.match(html, /may not pay everyone/i); }); test('every mode gets its own guidance, and none is called solo', async () => { diff --git a/dashboard/views/partial/about-numbers.ejs b/dashboard/views/partial/about-numbers.ejs index fa5913c..80a5af4 100644 --- a/dashboard/views/partial/about-numbers.ejs +++ b/dashboard/views/partial/about-numbers.ejs @@ -242,32 +242,33 @@ password: (ignored — any value) spendable.

    - <%# The disclosure this mode exists to make. A claim below the floor is - forfeited to the operator and never settled, so a miner too small to - clear it will mine here, submit valid shares, and earn nothing - indefinitely. That is defensible as a stated rule and indefensible as - a discovery, and the operator's log is the one place the miner it - costs cannot see. Only rendered when the proxy actually published a - floor -- an older proxy stores NULL, and inventing 546 there would be - stating someone else's policy for them. %> + <%# The disclosure this mode owes its miners. A coinbase has a fixed budget + of bytes, so one block cannot pay everyone in a large window -- and + what it cannot pay goes to the OTHER MINERS, never to the operator. + Small miners are paid less OFTEN here, not less. Only rendered when + the proxy actually published a floor: an older proxy stores NULL, and + inventing 546 would state someone else's policy for them. %> <% if (_p && _p.pplns_payout_floor_sats != null) { %>

    - There is a minimum, and it is not held over. - If your share of a block comes to less than - <%= _satsInt(_p.pplns_payout_floor_sats) %> sats, you - get no output in that coinbase and the amount goes to the operator. - It is not carried forward and not - paid later. A coinbase is a fixed budget of bytes and an output too - small to be worth its own space cannot be written, so this pool - forfeits it rather than keep a balance it would have to custody. + A single block may not pay everyone. A coinbase has + room for only so many outputs, so if your share of a block comes to + less than <%= _satsInt(_p.pplns_payout_floor_sats) %> sats, + or there is simply no room left in it, you get no output in + that block.

    - In practice that is a floor on how small a miner this pool is worth - using. Below it you would mine here and earn nothing, however long you - stayed — worse than mining solo, where you at least hold a lottery - ticket on a whole block. Work out your expected share of a block - before pointing a rig here, and ask the operator if you are near the - line. + What you were owed does not go to the pool: it is shared out + among the miners that block could pay, and the operator still + takes only its fee. You are then first in the queue + for the next one — a share of every coinbase's slots is reserved for + whoever has waited longest, so being small here means being paid + less often, not less. +

    +

    + Nothing is held on your behalf, and there is no balance to withdraw. + That queue records whose turn it is, not money the pool owes you: if it + were deleted tomorrow nobody would be short a payment — the pool would + simply forget the order.

    <% } %> diff --git a/docs/simplepool.html b/docs/simplepool.html index f603594..1aa4f30 100644 --- a/docs/simplepool.html +++ b/docs/simplepool.html @@ -482,54 +482,53 @@

    pool_mode = pplns-coinbase

    - pplns-coinbase forfeits small claims to the operator + pplns-coinbase pays what one block cannot fit to the other miners

    A coinbase is a fixed budget of bytes, and every payout spends some of - it. Two limits follow, and both cost miners money rather than the - pool: coinbase_max_bytes (default 1000) budgets the - whole serialized coinbase, commitments included, because that is what a - rented-hashrate marketplace measures when it refuses a job as oversized; - and pplns_payout_floor_sats (default 546, the dust limit) is - the least a claim must be worth to get an output at all. + it. Two limits follow: coinbase_max_bytes (default 1000) + budgets the whole serialized coinbase, commitments included, because that + is what a rented-hashrate marketplace measures when it refuses a job as + oversized — settable per listener, since the ceiling only binds on the + port the rented hashrate connects to. And + pplns_payout_floor_sats (default 546, the dust limit) is the + least a claim must be worth to get an output at all.

    - A claim that clears neither is forfeited to the operator — not - carried, not recorded, and not settled later. That is deliberate. - There is nowhere to hold it, because the payment is the block, - and carrying it would rebuild exactly the custodial ledger this mode - exists to delete. The consequence is a hashrate floor: a miner too small - to clear it will mine here, submit valid shares, and earn nothing - indefinitely — strictly worse for them than solo mining, where they at - least hold a lottery ticket. + A claim that clears neither is shared out among the miners that + block could pay — never given to the operator, which still takes + only its fee. The block pays out to the satoshi and the pool holds + nothing.

    - Because that is a trap unless it is visible, the floor is disclosed four - ways: stated at startup, reported per template as the count of miners - about to be excluded, reported per block as the claims actually - forfeited — and published to pool_meta, so the - dashboard states it to miners before they connect. - That last one is the one that matters: the operator's log is the one - place the miner it costs cannot look. + Being small therefore costs you frequency, not money. A + miner's share of the window tracks its hashrate, so without help the + largest claims would take the same slots in every block and the same + addresses would never be paid at all. A quarter of each coinbase's slots + are reserved for whoever has waited longest, tracked as a signed fraction + of one block reward per worker that sums to zero. That is + not a balance: nothing is withheld from a coinbase and + released later, and deleting the table would cost nobody a payment — the + pool would just forget whose turn it was. +

    +

    + The floor is stated at startup, per template, per block, and on the + dashboard before a miner connects, because the operator's log is the one + place the miner it affects cannot look.

    - Why the PPLNS modes exist at all -

    - PPS prices a share the moment it arrives, whether or not it ever becomes - a block. Somebody has to fund the gap between what has been promised and - what has been mined, and that somebody is the operator — who therefore - needs a reserve measured in block rewards, and who is ruined by a long - enough run of bad luck. That is a real barrier: a pool that cannot fund - the reserve cannot honestly run PPS. -

    + This was the other way round, and measurement changed it

    - PPLNS removes it by never promising anything in advance. A block is - divided among the work that produced it, so 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 the trade, stated plainly, and the reason the operator - fee is normally set lower here: there is no risk premium to charge for. + Until #76 + a dropped claim rode on the operator's output, defended as a dust + policy. With 100 miners on a 1/n hashrate spread and the default budget, + 28 were paid, 72 were cut by the byte cap and none by the dust + floor, and the operator received 25% of the block on a + 1% fee. The take also rose as the coinbase shrank — 46% at 400 + bytes against 2% at 3000 — so starving your own miners was the + revenue-maximising move. Neither the dust framing nor the incentive + survived contact with the numbers.

    @@ -572,9 +571,9 @@

    pool_mode = pplns-coinbase

    simplepool-payout.service same, with PAYOUT_RAIL=btc not installed - A claim too small to paycannot arise + A claim one block cannot paycannot arise accrues until the payout worker can batch it - forfeited to the operator, permanently + shared out among the miners that block could pay; you go first in the queue for the next one Miner's incomelumpy and rare, but completesmooth and proportional proportional, but only when the pool finds a block the same, above the payout floor — nothing below it @@ -1550,17 +1549,18 @@

    pplns-thunder / pplns-btc — paid when a block ma

    pplns-coinbase — the same accounting, no custody

    The window is snapshotted onto the job when the template is built, so the - coinbase carries one output per miner in it. No pool wallet, no ledger row, - no maturity wait — and no way to hold a claim too small to be worth an - output, which is why this mode has a payout floor and forfeits what falls - below it. See the five modes for the policy in full. + coinbase carries one output per miner it has room for. No pool wallet, no + ledger row, no maturity wait. A coinbase only fits so many payouts, so what + one block cannot pay is shared among the miners it could — never the + operator — and whoever was left out goes first in the queue for the next + block. See the five modes for the policy in full.

    - - pplns-coinbase: the block pays the whole window, directly - The window is snapshotted onto the job when the template is built, so the coinbase carries one output per miner. There is no pool wallet, no ledger and no maturity wait — but a claim below the payout floor is forfeited to the operator. + + pplns-coinbase: the block pays the whole window, directly + The window is snapshotted onto the job when the template is built, so the coinbase carries one output per miner it has room for. There is no pool wallet, no ledger and no maturity wait. What one block cannot fit is shared among the miners it could pay, and those left out go first in the queue for the next block.

    pplns-coinbase — the same accounting, no custody

    MinerASICsimplepool:3334shares.dbSQLiteBitcoin L1the chain - - who is in the window NOW?claims, largest firstsplit by difficulty; drop anything under the floorA claim below pplns_payout_floor_sats is FORFEITED to the operator — not carried, not settled later.notify — pays the whole windowsubmit (a block)submitblockEveryone above the floor is paid, in this block. No ledger row is ever written. + + who is in the window NOW?claims + who has waited longestorder: biggest claims, plus reserved slotsA coinbase fits only so many outputs. What it cannot pay is shared among the miners it can — never theoperator, who takes only its fee.notify — pays the whole windowsubmit (a block)submitblockstage who was skipped (sums to zero)Staged, not applied: an orphaned block paid nobody and rotates nobody. The confirmation pass decides.Nobody is owed money — only a turn. Skipped miners go first in the next block.
    @@ -2185,8 +2185,9 @@

    Configuration

    connects to, and every byte of it costs a payout. pplns_payout_floor_sats546 pplns-coinbase only. A claim worth less than this is not paid, - and is forfeited to the operator — nothing is carried and nothing settles - later. Clamped up to 546, the dust limit. Disclose it to your miners. + in that block; what it was owed is shared among the miners the block + could pay, and it goes first in the queue for the next one. Clamped up to 546, + the dust limit. Disclose it to your miners. operator_addressRequired. Receives the fee_bps cut. The proxy refuses to start without it. fee_bps100 diff --git a/payout/README.md b/payout/README.md index f75b236..ba797e5 100644 --- a/payout/README.md +++ b/payout/README.md @@ -33,8 +33,9 @@ username even is. > If you are looking for where a `pplns-coinbase` miner gets paid: in the > block, at the moment it is found, one coinbase output per miner. See the > mode's section in [../README.md](../README.md#the-five-modes) — including -> the payout floor, below which a claim is forfeited to the operator rather -> than accrued here. +> the payout floor, below which a claim is shared among the miners that block +> could pay rather than accrued here. Nothing is ever owed, so there is still +> nothing for this worker to settle. Everything that makes a payout safe is written once and shared: the write-ahead `payouts_in_flight` row, one transaction per batch, and crediting diff --git a/tests/README.md b/tests/README.md index 3633e55..f212ae4 100644 --- a/tests/README.md +++ b/tests/README.md @@ -94,8 +94,9 @@ the version last validated against. pool stopped. That is replaying the pool's own record of accepted work, not stubbing what is under test: the window query, the split, the builder, the block and the outputs read back off the chain are all real, and the - forfeited amount is asserted against the arithmetic. Own `.regtest-cbwin/` - dir. + redistributed amount is asserted against the arithmetic — as is the operator + holding exactly its fee, and the payout queue summing to zero. Own + `.regtest-cbwin/` dir. Every one-shot test allocates its stack ports dynamically per run, so they can run concurrently — with each other and with a dev stack from From 02a3451ad2e52126466fce63d9c382352b47853c Mon Sep 17 00:00:00 2001 From: rob Date: Wed, 9 Sep 2026 09:15:51 +0200 Subject: [PATCH 22/36] docs: commit the sequence-diagram generator, and have CI check it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five diagrams in docs/simplepool.html were machine-generated and the generator existed only in a scratch directory. That is how documentation goes stale: the committed artefact is ~4 KB per diagram of computed coordinates, so the cheap path for the next person is to edit the prose around it and leave the picture alone. Not hypothetical. These diagrams already went stale once, in the space of a day — the pplns-coinbase forfeit rule was reversed and "FORFEITED to the operator" stayed drawn into the SVG, contradicting the paragraph directly above it. docs/sequence-diagrams.py now holds the drawing code and the specs, and splices its output between HTML comment markers so a regeneration replaces exactly the drawings and nothing around them. Prose about a diagram lives outside the markers and stays hand-written. python3 docs/sequence-diagrams.py # rewrite in place python3 docs/sequence-diagrams.py --check # fail if out of date One bug fixed to make that work: element ids came from Python's hash(), which is randomised per process, so every run produced a different id and the script could not reproduce its own output. They are sha1-derived now, which is what makes --check meaningful — a no-op run is byte-identical. CI runs --check in check_build.yaml. Verified it fails on a hand-edited diagram and passes on the committed one, and the regenerated page still renders correctly in light and dark. The only change to the HTML is the markers, the whitespace around
    , and the new deterministic ids; no diagram was redrawn. --- .github/workflows/check_build.yaml | 7 + docs/sequence-diagrams.py | 316 +++++++++++++++++++++++++++++ docs/simplepool.html | 50 +++-- tests/README.md | 22 ++ 4 files changed, 375 insertions(+), 20 deletions(-) create mode 100755 docs/sequence-diagrams.py diff --git a/.github/workflows/check_build.yaml b/.github/workflows/check_build.yaml index 26ebb1a..d00a83f 100644 --- a/.github/workflows/check_build.yaml +++ b/.github/workflows/check_build.yaml @@ -40,3 +40,10 @@ jobs: - name: Test run: make test + + # The sequence diagrams in docs/simplepool.html are generated, and they + # have gone stale once already — the pplns-coinbase forfeit rule was + # reversed and the old wording stayed drawn into the SVG. This fails if + # the committed HTML no longer matches the specs it came from. + - name: Docs diagrams are up to date + run: python3 docs/sequence-diagrams.py --check diff --git a/docs/sequence-diagrams.py b/docs/sequence-diagrams.py new file mode 100755 index 0000000..943deb2 --- /dev/null +++ b/docs/sequence-diagrams.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +"""Regenerate the sequence diagrams in docs/simplepool.html. + +The diagrams are inline SVG, drawn in the page's own idiom: no library, no +external requests, and every colour taken from the page's CSS variables so they +follow the reader's theme. They are also ~4 KB each of hand-computed +coordinates, which is why this script exists rather than the SVG being edited in +place: the diagrams have already gone stale once, when the pplns-coinbase forfeit +rule was reversed and the words "FORFEITED to the operator" were baked into the +picture (LayerTwo-Labs/simplepool#76). + +Usage: + python3 docs/sequence-diagrams.py # rewrite the diagrams in place + python3 docs/sequence-diagrams.py --check # fail if they are out of date + +To change a diagram, edit DIAGRAMS at the bottom and re-run. Output is +deterministic, so a no-op run leaves the file byte-identical. +""" + +import re +import sys +import hashlib +from pathlib import Path + +ESC = {'&': '&', '<': '<', '>': '>'} +def e(s): return ''.join(ESC.get(c, c) for c in s) + +W_ACTOR = 132 # actor box width +GAP = 26 # gap between actor boxes +TOP = 34 # y of actor box top +H_ACTOR = 46 +FIRST_MSG = 118 # y of first message +STEP = 34 # vertical distance between messages +NOTE_PAD = 8 + + +def wrap(text, max_px, px=10.5, adv=0.52): + """Greedy wrap so a note never runs outside its own box. The generator + owns this rather than the author: a note that overflows is invisible in + the source and obvious only once rendered.""" + per = max_px / (px * adv) + words, lines, cur = text.split(), [], '' + for w in words: + trial = (cur + ' ' + w).strip() + if len(trial) <= per or not cur: + cur = trial + else: + lines.append(cur); cur = w + if cur: lines.append(cur) + return lines + + +def build(title, desc, actors, steps, accent="pplns", min_width=0): + """actors: [(key, label, sub)] ; steps: list of tuples, see below.""" + n = len(actors) + width = max(n * W_ACTOR + (n - 1) * GAP + 20, min_width) + span = (width - 20 - W_ACTOR) / max(n - 1, 1) + x = {} + for i, (k, _, _) in enumerate(actors): + x[k] = 10 + i * span + W_ACTOR / 2 + + body, y = [], FIRST_MSG + for st in steps: + kind = st[0] + if kind == 'msg': + _, a, b, label = st[:4] + dashed = len(st) > 4 and 'dashed' in st[4] + x1, x2 = x[a], x[b] + back = x2 < x1 + x1 += (-4 if back else 4); x2 += (5 if back else -5) + cls = 'ln dash' if dashed else 'ln' + body.append(f'') + body.append(f'{e(label)}') + y += STEP + elif kind == 'self': + _, a, label = st[:3] + x1 = x[a] + # A self-message on the RIGHTMOST lifeline has nowhere to put a + # left-anchored label, so mirror the loop and the text inward. + # Cheaper than shortening every label to fit the worst case. + mirror = x1 > width * 0.62 + d = -1 if mirror else 1 + body.append(f'') + body.append(f'{e(label)}') + y += STEP + 4 + elif kind == 'note': + _, text = st[:2] + tone = st[2] if len(st) > 2 else 'plain' + lines = wrap(text, width - 44) + h = 12 + 14 * len(lines) + body.append(f'') + for j, ln in enumerate(lines): + body.append(f'{e(ln)}') + y += h + 12 + elif kind == 'gap': + y += st[1] + + bottom = y - STEP + 22 + life = [] + for k, label, sub in actors: + cx = x[k] + life.append(f'') + heads = [] + for i, (k, label, sub) in enumerate(actors): + bx = 10 + i * span + cx = bx + W_ACTOR / 2 + cls = 'abx accent' if k == 'pool' else 'abx' + heads.append(f'') + heads.append(f'{e(label)}') + if sub: + heads.append(f'{e(sub)}') + + # Deterministic: Python's hash() is randomised per process, so the ids + # moved on every run and a no-op regeneration produced a diff. + tid = 'sq' + hashlib.sha1(title.encode()).hexdigest()[:6] + return f'''
    +
    + + {e(title)} + {e(desc)} + + + + + + + {''.join(heads)} + {''.join(life)} + {''.join(body)} + +
    +
    ''' + + + +# ---- the diagrams --------------------------------------------------------- + +DIAGRAMS = {} + +MINER = ('miner', 'Miner', 'ASIC') +POOL = ('pool', 'simplepool', ':3334') +NODE = ('node', 'bitcoind', '+ enforcer') +CHAIN = ('chain', 'Bitcoin L1', 'the chain') +DB = ('db', 'shares.db', 'SQLite') +WORK = ('worker','payout worker', 'systemd') +THUN = ('thun', 'Thunder', 'sidechain #9') + +# ---------------------------------------------------------------- solo ----- +DIAGRAMS['solo'] = build( + 'solo mode: the finder is paid in the block it found', + 'A miner subscribes, simplepool builds a coinbase paying that miner and ' + 'hands out work. When the miner finds a block the coinbase already pays it, ' + 'so no ledger and no payout step exist.', + [MINER, POOL, NODE, CHAIN], + [('msg', 'miner', 'pool', 'authorize '), + ('msg', 'pool', 'node', 'getblocktemplate'), + ('msg', 'node', 'pool', 'template', 'dashed'), + ('self', 'pool', 'build coinbase paying THIS miner'), + ('msg', 'pool', 'miner', 'notify (cb1 / cb2)'), + ('msg', 'miner', 'pool', 'submit (a block!)'), + ('msg', 'pool', 'node', 'submitblock'), + ('msg', 'node', 'chain', 'block accepted'), + ('note', 'The miner is already paid — the coinbase is the payment. No ledger, no payout worker, no wait.', 'win'), + ], accent='solo') + +# --------------------------------------------------------- pps-classic ----- +DIAGRAMS['pps'] = build( + 'pps-classic: every share is priced on arrival, the pool carries the risk', + 'Each accepted share is credited immediately at a rate derived from the ' + 'template. The coinbase pays the pool wallet, and a payout worker settles ' + 'balances over Thunder on a daily batch.', + [MINER, POOL, DB, WORK, THUN], + [('msg', 'miner', 'pool', 'authorize '), + ('msg', 'miner', 'pool', 'submit (an ordinary share)'), + ('self', 'pool', 'price it: rate x difficulty'), + ('msg', 'pool', 'db', 'credit NOW, block or not'), + ('note', 'The pool owes this miner before it has earned anything. That gap is the operator reserve.', 'warn'), + ('msg', 'miner', 'pool', 'submit (a block)'), + ('note', 'The coinbase pays the POOL wallet, not the miner.'), + ('gap', 6), + ('msg', 'worker', 'db', 'daily: who is owed?'), + ('msg', 'worker', 'thun', 'one batched transfer'), + ('msg', 'worker', 'db', 'credit paid_sats on CONFIRMATION', 'dashed'), + ], accent='pps') + +# ------------------------------------------------- pplns-thunder / btc ----- +DIAGRAMS['pplns_custodial'] = build( + 'pplns-thunder and pplns-btc: a matured block is split across the work that found it', + 'Shares are recorded but not priced. When a block reaches 100 confirmations ' + 'it is divided across the window of shares that produced it, and the payout ' + 'worker settles the resulting balances over Thunder or on Bitcoin L1.', + [MINER, POOL, DB, WORK], + [('msg', 'miner', 'pool', 'submit (an ordinary share)'), + ('msg', 'pool', 'db', 'record it — credited 0'), + ('note', 'Nothing is promised. The pool never owes more than it has just been paid.', 'win'), + ('msg', 'miner', 'pool', 'submit (a block)'), + ('msg', 'pool', 'db', 'row: hash + window size'), + ('gap', 4), + ('note', '...100 confirmations later, on a new tip...'), + ('self', 'pool', 'reconcile: still in the chain?'), + ('msg', 'pool', 'db', 'split the block across the window'), + ('gap', 4), + ('msg', 'worker', 'db', 'daily: who is owed?'), + ('self', 'worker', 'pay: Thunder, or L1 via the enforcer'), + ('msg', 'worker', 'db', 'paid_sats on confirmation', 'dashed'), + ]) + +# ------------------------------------------------------ pplns-coinbase ----- +DIAGRAMS['cbwin'] = build( + 'pplns-coinbase: the block pays the whole window, directly', + 'The window is snapshotted onto the job when the template is built, so the ' + 'coinbase carries one output per miner it has room for. There is no pool ' + 'wallet, no ledger and no maturity wait. What one block cannot fit is ' + 'shared among the miners it could pay, and those left out go first in the ' + 'queue for the next block.', + [MINER, POOL, DB, CHAIN], + [('msg', 'pool', 'db', 'who is in the window NOW?'), + ('msg', 'db', 'pool', 'claims + who has waited longest', 'dashed'), + ('self', 'pool', 'order: biggest claims, plus reserved slots'), + ('note', 'A coinbase fits only so many outputs. What it cannot pay is shared among the miners it can — never the operator, who takes only its fee.'), + ('msg', 'pool', 'miner', 'notify — pays the whole window'), + ('msg', 'miner', 'pool', 'submit (a block)'), + ('msg', 'pool', 'chain', 'submitblock'), + ('msg', 'pool', 'db', 'stage who was skipped (sums to zero)'), + ('note', 'Staged, not applied: an orphaned block paid nobody and rotates nobody. The confirmation pass decides.', 'warn'), + ('note', 'Nobody is owed money — only a turn. Skipped miners go first in the next block.', 'win'), + ]) + +# ------------------------------------------------------------- payouts ----- +DIAGRAMS['payout'] = build( + 'the payout worker: never pay twice, never claim to have paid', + 'A write-ahead in-flight row is written before the transaction is sent, so ' + 'a crash mid-payout is recoverable; paid_sats is credited only once the ' + 'transaction confirms.', + [WORK, DB, ('rail', 'the rail', 'Thunder / L1')], + [('msg', 'worker', 'db', 'who clears PAYOUT_MIN_SATS?'), + ('msg', 'worker', 'db', 'write in-flight row FIRST'), + ('note', 'Written before the money moves. A crash here is recoverable; the reverse order is not.', 'warn'), + ('msg', 'worker', 'rail', 'ONE transaction for the whole batch'), + ('msg', 'rail', 'worker', 'txid', 'dashed'), + ('msg', 'worker', 'db', 'store txid against the in-flight row'), + ('gap', 4), + ('note', '...later ticks, until it confirms...'), + ('self', 'worker', 'is the txid confirmed yet?'), + ('msg', 'worker', 'db', 'NOW credit paid_sats, clear in-flight'), + ('note', 'Crediting on confirmation, not on send, is what makes a lost transaction a retry rather than a theft.', 'win'), + ], accent='pps', min_width=560) + + + +# ---- splicing ------------------------------------------------------------- +# +# Each figure sits between HTML comment markers so a regeneration replaces +# exactly the drawing and nothing around it. Prose about a diagram lives +# outside the markers and is written by hand. + +HTML = Path(__file__).resolve().parent / "simplepool.html" + + +def splice(src: str) -> str: + out = src + for key, svg in DIAGRAMS.items(): + begin, end = f"", f"" + i, j = out.find(begin), out.find(end) + if i < 0 or j < 0: + sys.exit(f"marker {begin} missing from {HTML.name}; add it around " + f"the figure this diagram belongs to") + out = out[:i + len(begin)] + "\n" + svg + "\n" + out[j:] + return out + + +def main() -> int: + check = "--check" in sys.argv[1:] + src = HTML.read_text() + new = splice(src) + if new == src: + print(f"{HTML.name}: {len(DIAGRAMS)} diagram(s) already up to date") + return 0 + if check: + print(f"{HTML.name}: diagrams are STALE — run " + f"`python3 docs/{Path(__file__).name}` and commit the result", + file=sys.stderr) + return 1 + HTML.write_text(new) + print(f"{HTML.name}: rewrote {len(DIAGRAMS)} diagram(s)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/simplepool.html b/docs/simplepool.html index 1aa4f30..f234052 100644 --- a/docs/simplepool.html +++ b/docs/simplepool.html @@ -1433,12 +1433,13 @@

    solo — paid in the block you found

    served a job whose coinbase pays that miner. Finding a block and being paid are the same event; there is nothing after it.

    -
    + +
    - solo mode: the finder is paid in the block it found - A miner subscribes, simplepool builds a coinbase paying that miner and hands out work. When the miner finds a block the coinbase already pays it, so no ledger and no payout step exist. + aria-labelledby="sq69a9b7t sq69a9b7d" style="max-width: 626px; margin: 0 auto;"> + solo mode: the finder is paid in the block it found + A miner subscribes, simplepool builds a coinbase paying that miner and hands out work. When the miner finds a block the coinbase already pays it, so no ledger and no payout step exist.

    solo — paid in the block you found

    +

    pps-classic — paid on arrival, out of a reserve

    @@ -1473,12 +1475,13 @@

    pps-classic — paid on arrival, out of a reserve

    earned any, and the gap has to be funded by an operator reserve measured in block rewards.

    -
    + +
    - pps-classic: every share is priced on arrival, the pool carries the risk - Each accepted share is credited immediately at a rate derived from the template. The coinbase pays the pool wallet, and a payout worker settles balances over Thunder on a daily batch. + aria-labelledby="sqa8556at sqa8556ad" style="max-width: 784px; margin: 0 auto;"> + pps-classic: every share is priced on arrival, the pool carries the risk + Each accepted share is credited immediately at a rate derived from the template. The coinbase pays the pool wallet, and a payout worker settles balances over Thunder on a daily batch.

    pps-classic — paid on arrival, out of a reserve

    +

    pplns-thunder / pplns-btc — paid when a block matures

    @@ -1513,12 +1517,13 @@

    pplns-thunder / pplns-btc — paid when a block ma there is no reserve to size. The two rails differ only in where the balance is finally settled, which is what a stratum username has to be.

    -
    + +
    - pplns-thunder and pplns-btc: a matured block is split across the work that found it - Shares are recorded but not priced. When a block reaches 100 confirmations it is divided across the window of shares that produced it, and the payout worker settles the resulting balances over Thunder or on Bitcoin L1. + aria-labelledby="sqc435b4t sqc435b4d" style="max-width: 626px; margin: 0 auto;"> + pplns-thunder and pplns-btc: a matured block is split across the work that found it + Shares are recorded but not priced. When a block reaches 100 confirmations it is divided across the window of shares that produced it, and the payout worker settles the resulting balances over Thunder or on Bitcoin L1.

    pplns-thunder / pplns-btc — paid when a block ma

    +

    pplns-coinbase — the same accounting, no custody

    @@ -1555,12 +1561,13 @@

    pplns-coinbase — the same accounting, no custody

    operator — and whoever was left out goes first in the queue for the next block. See the five modes for the policy in full.

    -
    + +
    - pplns-coinbase: the block pays the whole window, directly - The window is snapshotted onto the job when the template is built, so the coinbase carries one output per miner it has room for. There is no pool wallet, no ledger and no maturity wait. What one block cannot fit is shared among the miners it could pay, and those left out go first in the queue for the next block. + aria-labelledby="sq5b7c20t sq5b7c20d" style="max-width: 626px; margin: 0 auto;"> + pplns-coinbase: the block pays the whole window, directly + The window is snapshotted onto the job when the template is built, so the coinbase carries one output per miner it has room for. There is no pool wallet, no ledger and no maturity wait. What one block cannot fit is shared among the miners it could pay, and those left out go first in the queue for the next block.

    pplns-coinbase — the same accounting, no custody

    +

    The payout worker, in the three modes that have one

    @@ -1596,12 +1604,13 @@

    The payout worker, in the three modes that have one

    design: the write-ahead row goes in before the money moves, and paid_sats is credited only once the transaction confirms.

    -
    + +
    - the payout worker: never pay twice, never claim to have paid - A write-ahead in-flight row is written before the transaction is sent, so a crash mid-payout is recoverable; paid_sats is credited only once the transaction confirms. + aria-labelledby="sq96bd6dt sq96bd6dd" style="max-width: 560px; margin: 0 auto;"> + the payout worker: never pay twice, never claim to have paid + A write-ahead in-flight row is written before the transaction is sent, so a crash mid-payout is recoverable; paid_sats is credited only once the transaction confirms.

    The payout worker, in the three modes that have one

    +
    Why the order matters more than the rail diff --git a/tests/README.md b/tests/README.md index f212ae4..63472fb 100644 --- a/tests/README.md +++ b/tests/README.md @@ -109,3 +109,25 @@ One caveat about `test_integration.sh`, first in the list above: it looks like a solo end-to-end test and is not. It never mines, so it cannot see whether a coinbase pays the right person, and it is not in CI. That is what `test_solo_regtest.sh` was written for. + +## Generated documentation + +The sequence diagrams in `docs/simplepool.html` are inline SVG produced by +`docs/sequence-diagrams.py` — no library, no external requests, colours from +the page's own CSS variables so they follow the reader's theme. Do not edit +the SVG by hand: it is ~4 KB per diagram of computed coordinates, and the +script owns the layout, the note wrapping and the element ids. + + python3 docs/sequence-diagrams.py # rewrite the diagrams in place + python3 docs/sequence-diagrams.py --check # fail if they are out of date + +To change a diagram, edit the `DIAGRAMS` spec at the bottom of that script and +re-run it. Output is deterministic, so a no-op run leaves the file +byte-identical — which is what `--check` relies on, and what CI runs in +`check_build.yaml`. + +That guard exists because the diagrams have gone stale once already: the +`pplns-coinbase` rule that a claim too small to pay is forfeited to the +operator was reversed, and "FORFEITED to the operator" stayed drawn into the +picture. A diagram nobody can regenerate is a diagram that quietly stops being +true. From 2e65ce022b71cf8774b35902baa919d5c36a7aa9 Mon Sep 17 00:00:00 2001 From: Wired4ncer <102553581+Wired4ncer@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:42:58 -0600 Subject: [PATCH 23/36] pplns window: refuse a walk that cannot prove it covered the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit store_pplns_window() had three paths that returned a PARTIAL or over-wide window and reported success. The caller renders whatever comes back into a coinbase and publishes it — the payment IS the block — so nothing downstream can notice. This is the divergence the function's own comment warns about: "a block pays out differently from what its template promised", arriving through the error path rather than the arithmetic. 1. `if (sqlite3_step(b) != SQLITE_ROW) break;` — on iteration 2 or later cutoff_id still holds the previous iteration's boundary, which is KNOWN to be short of the window, because falling short is the only reason a second iteration happens. 2. The end-of-table probe had no error check at all. A failed prepare or step left `seen` at 0, so `seen < batch` was true and the loop broke on that same short boundary — it could not tell a failed probe from a short table. 3. A first-iteration failure left cutoff_id = 0, making the payout query `sh.id >= 0`: the whole table as the window, both the unbounded scan this code exists to remove and a window wider than configured. The walk now ends having PROVED one of two things: it covered the window, or it read the whole table. Anything else returns -2 and the caller keeps its last good template. Reachability: this connection is opened once with SQLITE_OPEN_FULLMUTEX and shared by the commit and template threads, so it cannot return BUSY against its own writer. The realistic triggers are IO error, NOMEM, a corrupt page, and cross-process BUSY outlasting the busy timeout — dashboard/lib/db-admin.js and payout/lib/db.js open this file read-write from other processes. Proved against the parent, where both injected failures are served as success: first step fails -> served 2000 for a 500 window (the whole table) fails after widening -> served 40,960 for a 50,000 window (short) The end-of-table question is now answered by COUNT(*) from the boundary query itself rather than a separate probe. In the only branch that reads it every row in the batch passes the filter — if any row were excluded, the row before it would have a running total at or past the window and `covered` would already have ended the walk — so `got < batch` means the table ran out, exactly, from the rows actually read. ⚠️ An intermediate version of this commit answered that question with a MIN(id) taken once before the loop, and that was worse than the bug it fixed: deleting the oldest row mid-walk lifts the real table minimum above the stale value, so neither exit test can fire. Measured on a 50-row table with a 1e9 window, it ran 27 iterations — each an unbounded scan and sort of the whole table, the exact pathology this batching exists to remove — until `batch` overflowed (UBSan: signed integer overflow, 4611686018427387904 * 4). The batch's own row count describes the rows read rather than the table as it is at that instant, so it cannot go stale. test_a_row_deleted_during_the_walk_still_terminates pins it, and `batch > INT64_MAX / 4` refuses rather than overflowing. tests/test_store_walk.c includes store.c as source and redirects sqlite3_step for the boundary query only. sqlite3_progress_handler was no good — it also fires for the store's background commit thread on the same connection — and an authorizer runs at prepare time, before the loop. The fault injection lives entirely in the test binary; production code carries no test seam. The suite also pins the three cases that must NOT become errors: a pool younger than its own window, an empty table, and a concurrent delete. One test exists only to pin the seam itself. Every other case returns before the payout query is reached, so none of them could catch a matcher that was too wide — and a too-wide matcher is how an earlier attempt at this suite went green for the wrong reason, by interrupting the payout query as well. test_the_injection_seam_does_not_reach_the_payout_query arms the injection with a budget the walk never spends and asserts the window still comes back whole, with exactly one statement ever matched. Widening the matcher to any statement touching `shares` makes that test, and only that test, abort. make test, make asan, and the new suite under -fsanitize=address,undefined are all clean. With src/store.c alone reverted, the two injection tests abort; the other five pass either way and are there as controls. --- Makefile | 7 +- src/store.c | 80 ++++++++--- tests/test_store_walk.c | 297 +++++++++++++++++++++++++++++++++++++++ tests/test_store_walk.mk | 8 ++ 4 files changed, 369 insertions(+), 23 deletions(-) create mode 100644 tests/test_store_walk.c create mode 100644 tests/test_store_walk.mk diff --git a/Makefile b/Makefile index b67787f..68c2586 100644 --- a/Makefile +++ b/Makefile @@ -125,12 +125,14 @@ include tests/test_thunder.mk include tests/test_config.mk include tests/test_reconcile.mk include tests/test_pplns.mk +include tests/test_store_walk.mk -test: build/test_share build/test_bitcoind build/test_stratum build/test_store build/test_coinbase build/test_broadcast build/test_thunder build/test_config build/test_reconcile build/test_pplns +test: build/test_share build/test_bitcoind build/test_stratum build/test_store build/test_store_walk build/test_coinbase build/test_broadcast build/test_thunder build/test_config build/test_reconcile build/test_pplns ./build/test_share ./build/test_bitcoind ./build/test_stratum ./build/test_store + ./build/test_store_walk ./build/test_coinbase ./build/test_broadcast ./build/test_thunder @@ -159,6 +161,8 @@ asan: src/log.c src/cjson/cJSON.c -lpthread $(CC) $(ASAN_CFLAGS) -o $(ASAN_DIR)/test_store tests/test_store.c \ src/store.c src/log.c $(PLATFORM_LDFLAGS) -lsqlite3 -lpthread + $(CC) $(ASAN_CFLAGS) -Wno-unused-function -o $(ASAN_DIR)/test_store_walk \ + tests/test_store_walk.c src/log.c $(PLATFORM_LDFLAGS) -lsqlite3 -lpthread $(CC) $(ASAN_CFLAGS) -o $(ASAN_DIR)/test_coinbase tests/test_coinbase.c \ src/coinbase.c src/sha256.c $(CC) $(ASAN_CFLAGS) -o $(ASAN_DIR)/test_share tests/test_share.c \ @@ -167,6 +171,7 @@ asan: src/pplns.c src/coinbase.c src/sha256.c ./$(ASAN_DIR)/test_stratum ./$(ASAN_DIR)/test_store + ./$(ASAN_DIR)/test_store_walk ./$(ASAN_DIR)/test_coinbase ./$(ASAN_DIR)/test_share ./$(ASAN_DIR)/test_pplns diff --git a/src/store.c b/src/store.c index fdd26d8..47d5d8f 100644 --- a/src/store.c +++ b/src/store.c @@ -11,6 +11,8 @@ #include "store.h" #include "log.h" +#include /* INT64_MAX */ + #include #include @@ -1472,8 +1474,8 @@ int store_pplns_window(store_t *s, double window_diff, * of a hundred million rows and half a minute per template -- the pool * would simply stop publishing work (LayerTwo-Labs/simplepool#76). * - * So walk backwards in bounded batches instead, doubling until the batch - * covers the window, and let the main query use the primary-key index from + * So walk backwards in bounded batches instead, growing x4 until the + * batch covers the window, and let the main query use the primary-key index from * the boundary id. A window is a small multiple of one block's expected * work, so the first batch almost always covers it; the loop exists for * the pathological cases (a difficulty crash, a freshly-lowered window) @@ -1484,7 +1486,7 @@ int store_pplns_window(store_t *s, double window_diff, * matching store_pplns_distribute() exactly. If these two ever disagree a * block pays out differently from what its template promised. */ static const char *QB = - "SELECT MIN(id), MAX(running) FROM (" + "SELECT MIN(id), MAX(running), COUNT(*) FROM (" " SELECT id, difficulty," " SUM(difficulty) OVER (ORDER BY id DESC ROWS UNBOUNDED PRECEDING) AS running" " FROM (SELECT id, difficulty FROM shares ORDER BY id DESC LIMIT ?)" @@ -1498,35 +1500,69 @@ int store_pplns_window(store_t *s, double window_diff, atomic_fetch_add(&s->pg_errors, 1); return -2; } - /* 4096 covers a 2x window at any sane share difficulty; the cap stops - * a pool whose entire history is smaller than one window from looping - * forever doubling past the end of the table. */ + /* 4096 covers a 2x window at any sane share difficulty; growth is x4. + * + * The walk must end having PROVED one of two things: that it covered + * the window, or that it read the whole table. Anything else -- an IO + * error, NOMEM, a corrupt page, or a cross-process BUSY outlasting the + * busy timeout (dashboard/ and payout/ open this file read-write) -- + * means the rows behind `cutoff_id` were never read, and `cutoff_id` + * still holds a boundary already known to be short of the window. + * + * ⛔ Returning that as a window is the one failure this function must + * never have. The caller renders it into a coinbase and publishes it; + * the payment IS the block, so nothing downstream can notice, and the + * block pays out differently from what its template promised -- the + * divergence the boundary rule above exists to prevent. So: error out, + * and let the caller keep its last good template. + * + * `got` is how many rows the batch actually returned. In the only + * branch that reads it (covered < window_diff) every row in the batch + * passes the filter -- if any row were excluded, the row before it + * would have a running total at or past the window, and `covered` + * would already have ended the walk -- so `got < batch` means the + * table ran out, exactly and from the SAME query. Asking a separate + * COUNT(*) instead would re-read the rows, and would answer about the + * table as it is at that instant rather than the batch just read: with + * rows being deleted concurrently the two disagree, and a stale + * end-of-table test can leave this loop unable to terminate. */ sqlite3_int64 batch = 4096; + int settled = 0, step_rc = SQLITE_OK; for (;;) { sqlite3_reset(b); sqlite3_bind_int64(b, 1, batch); sqlite3_bind_double(b, 2, window_diff); - if (sqlite3_step(b) != SQLITE_ROW) break; - if (sqlite3_column_type(b, 0) == SQLITE_NULL) break; /* no shares */ + step_rc = sqlite3_step(b); + if (step_rc != SQLITE_ROW) break; + sqlite3_int64 got = sqlite3_column_int64(b, 2); + /* No shares at all: no work, no window, no payees. Not an error. */ + if (got == 0) { settled = 1; break; } cutoff_id = sqlite3_column_int64(b, 0); double covered = sqlite3_column_double(b, 1); - /* Covered means the batch reached past the window. If it did not, - * the window extends further back than we read and the answer - * would silently be a partial window -- so widen and retry. */ - if (covered >= window_diff) break; - sqlite3_int64 seen = 0; - sqlite3_stmt *c = NULL; - if (sqlite3_prepare_v2(s->db, - "SELECT COUNT(*) FROM (SELECT 1 FROM shares LIMIT ?)", - -1, &c, NULL) == SQLITE_OK) { - sqlite3_bind_int64(c, 1, batch); - if (sqlite3_step(c) == SQLITE_ROW) seen = sqlite3_column_int64(c, 0); - sqlite3_finalize(c); - } - if (seen < batch) break; /* read the whole table already */ + /* Covered means the batch reached past the window. */ + if (covered >= window_diff) { settled = 1; break; } + /* The batch could not be filled, so there is nothing further back + * to read: the pool is younger than its own window and pays across + * everything it has, as store_pplns_distribute() does. A complete + * answer, not a truncated one. */ + if (got < batch) { settled = 1; break; } + /* Refuse rather than overflow. Unreachable on any real table -- + * it would need more than 2^61 rows -- but `batch` is signed and + * multiplying past the maximum is undefined, not merely large. */ + if (batch > INT64_MAX / 4) break; batch *= 4; } sqlite3_finalize(b); + if (!settled) { + if (errbuf && errlen) + snprintf(errbuf, errlen, + "pplns window walk did not cover %.0f (stopped at id %lld): %s", + window_diff, (long long)cutoff_id, + step_rc == SQLITE_ROW ? "batch limit exhausted" + : sqlite3_errstr(step_rc)); + atomic_fetch_add(&s->pg_errors, 1); + return -2; + } } static const char *Q = diff --git a/tests/test_store_walk.c b/tests/test_store_walk.c new file mode 100644 index 0000000..d6db9f2 --- /dev/null +++ b/tests/test_store_walk.c @@ -0,0 +1,297 @@ +/* White-box test for the PPLNS boundary walk in store_pplns_window(). + * + * What is being pinned: when the walk cannot PROVE it covered the window, the + * function must return an error, never a window. The caller renders whatever + * it returns into a coinbase and publishes it; the payment IS the block, so a + * wrong window is mined, irreversible, and invisible afterwards. + * + * HOW the failure is reproduced. The interesting failures are a sqlite step + * that fails PART WAY THROUGH the widening loop -- BUSY from the share writer + * committing under WAL, INTERRUPT, NOMEM. Two things rule out the obvious + * approaches: sqlite3_progress_handler fires for the store's background commit + * thread on the same connection, so it cannot single out one statement; and an + * authorizer runs at prepare time, before the loop. So this file includes + * store.c as source with sqlite3_step redirected to a wrapper that fails the + * boundary query on demand, and nothing else. That keeps the fault injection + * inside the test binary -- production code carries no test seam. */ +#include +#include +#include +#include +#include +#include + +static int tsw_step(sqlite3_stmt *st); +static void maybe_delete_oldest(sqlite3_stmt *st); + +/* Redirect every sqlite3_step() inside store.c to the wrapper below. */ +#define sqlite3_step tsw_step +#include "../src/store.c" +#undef sqlite3_step + +/* -1 disables injection. Otherwise: let the boundary query succeed this many + * times, then fail it. 0 fails its very first step. */ +static int g_fail_qb_after = -1; +static int g_qb_steps = 0; + +static int tsw_step(sqlite3_stmt *st) { + const char *sql = sqlite3_sql(st); + /* The boundary query, and only it. store_pplns_distribute()'s Q_WINDOW also + * carries ROWS UNBOUNDED PRECEDING and runs on this same connection through + * this same wrapper, so the window frame alone does not identify the + * statement. The `MIN(id), MAX(running)` projection does, and the LIMIT + * narrows it further; Q_WINDOW has neither. */ + int is_qb = sql && strstr(sql, "ROWS UNBOUNDED PRECEDING") != NULL + && strstr(sql, "LIMIT ?") != NULL + && strstr(sql, "MIN(id), MAX(running)") != NULL; + if (is_qb && g_fail_qb_after >= 0 && g_qb_steps++ >= g_fail_qb_after) + return SQLITE_INTERRUPT; + maybe_delete_oldest(st); + return sqlite3_step(st); +} + +static char g_path[512]; +static const char *fresh_path(void) { + static int seq = 0; + snprintf(g_path, sizeof g_path, "/tmp/sp_walk_%d_%d.db", (int)getpid(), ++seq); + unlink(g_path); + char side[520]; + snprintf(side, sizeof side, "%s-wal", g_path); unlink(side); + snprintf(side, sizeof side, "%s-shm", g_path); unlink(side); + return g_path; +} + +static store_t *open_with_shares(const char *path, int nshares, double diff) { + store_cfg_t cfg = {0}; + snprintf(cfg.path, sizeof cfg.path, "%s", path); + cfg.commit_window_ms = 20; + cfg.commit_max_shares = 20000; + store_t *s = NULL; + assert(store_open(&cfg, &s) == 0); + for (int i = 0; i < nshares; ++i) { + char name[32], addr[48]; + snprintf(name, sizeof name, "w%d", i % 8); + snprintf(addr, sizeof addr, "addr_%d", i % 8); + assert(store_record_share_addr(s, name, addr, 1000ULL + (uint64_t)i, + diff, 0, NULL, 0, 0.0) == 0); + } + assert(store_flush(s) == 0); + return s; +} + +/* The precondition. Without it the injection tests below could pass against a + * store_pplns_window() that never worked at all -- "correctly refused" and + * "never returned anything" look identical from outside. */ +static void test_the_walk_serves_the_configured_window_when_nothing_fails(void) { + const char *path = fresh_path(); + store_t *s = open_with_shares(path, 200, 10.0); + g_fail_qb_after = -1; + + store_window_entry_t win[16]; + size_t n = 0; double total = 0.0; int trunc = 0; + char err[256] = {0}; + int rc = store_pplns_window(s, 500.0, win, 16, &n, &total, &trunc, err, sizeof err); + assert(rc > 0); + assert(n > 0); + /* 500 configured, one 10-difficulty share of slack for the share that + * crosses the boundary. NOT the table's 2000. */ + assert(total >= 500.0 && total <= 510.0); + store_close(s); + unlink(path); + printf(" ok test_the_walk_serves_the_configured_window_when_nothing_fails\n"); +} + +/* Path 3: the boundary query fails on its FIRST step, so cutoff_id is never + * assigned. Before the fix it stayed 0, the payout query became `sh.id >= 0`, + * and the whole table was served as the window -- unbounded, and four times + * what was configured here. */ +static void test_a_walk_that_fails_immediately_does_not_serve_the_whole_table(void) { + const char *path = fresh_path(); + store_t *s = open_with_shares(path, 200, 10.0); + + store_window_entry_t win[16]; + size_t n = 12345; double total = -1.0; int trunc = 0; + char err[256] = {0}; + + g_fail_qb_after = 0; g_qb_steps = 0; + int rc = store_pplns_window(s, 500.0, win, 16, &n, &total, &trunc, err, sizeof err); + g_fail_qb_after = -1; + + if (rc >= 0) { + fprintf(stderr, " FAIL: served rc=%d n=%zu total=%.0f " + "(configured window 500, whole table 2000)\n", rc, n, total); + assert(rc < 0 && "a walk that never read a row must not serve a window"); + } + assert(n == 0); + /* The reason must name the failure, not paper over it. An earlier version + * appended sqlite3_errmsg() unconditionally, so an exit that was not a + * step failure reported the literal string "not an error". */ + assert(err[0] != '\0'); + assert(strstr(err, "did not cover") != NULL); + assert(strstr(err, "not an error") == NULL); + printf(" reason: %s\n", err); + store_close(s); + unlink(path); + printf(" ok test_a_walk_that_fails_immediately_does_not_serve_the_whole_table\n"); +} + +/* Path 1: the walk widens, then the second step fails. cutoff_id then holds + * the FIRST iteration's boundary -- known to be short of the window, because + * falling short is the only reason a second iteration happens at all. */ +static void test_a_walk_that_fails_after_widening_does_not_serve_a_short_window(void) { + const char *path = fresh_path(); + /* 6000 shares x 10 = 60,000. A 50,000 window needs 5,000 rows, past the + * 4,096 first batch, so the walk must widen exactly once. */ + store_t *s = open_with_shares(path, 6000, 10.0); + + /* Precondition: prove the walk really does widen here, by checking the + * un-injected answer needs more than the first batch. */ + g_fail_qb_after = -1; + store_window_entry_t ref[16]; + size_t rn = 0; double rtotal = 0.0; int rt = 0; char rerr[256] = {0}; + assert(store_pplns_window(s, 50000.0, ref, 16, &rn, &rtotal, &rt, rerr, sizeof rerr) > 0); + assert(rtotal >= 50000.0 && rtotal <= 50010.0); + + store_window_entry_t win[16]; + size_t n = 12345; double total = -1.0; int trunc = 0; char err[256] = {0}; + g_fail_qb_after = 1; g_qb_steps = 0; /* first batch ok, widened step fails */ + int rc = store_pplns_window(s, 50000.0, win, 16, &n, &total, &trunc, err, sizeof err); + g_fail_qb_after = -1; + + if (rc >= 0) { + fprintf(stderr, " FAIL: served rc=%d n=%zu total=%.0f after a truncated " + "widening (configured 50000)\n", rc, n, total); + assert(rc < 0 && "a walk cut short mid-widening must not serve a window"); + } + assert(n == 0); + store_close(s); + unlink(path); + printf(" ok test_a_walk_that_fails_after_widening_does_not_serve_a_short_window\n"); +} + +/* Rows deleted while the walk runs must not stop it terminating. + * + * An earlier version of this fix answered "have I read the whole table?" with + * a MIN(id) taken ONCE before the loop. Deleting the oldest row lifts the real + * table minimum above that stale value, so the comparison can never come true; + * with a window wider than what is left, neither can the covered test, and the + * loop ran 27 times -- each iteration an unbounded scan and sort of the whole + * table, which is the exact pathology this batching exists to remove -- until + * `batch` overflowed. Answering from the batch's own row count instead is what + * makes this terminate, because it describes the rows actually read. */ +static int g_delete_on_qb_step = 0; +static sqlite3 *g_delete_db = NULL; + +static void maybe_delete_oldest(sqlite3_stmt *st) { + const char *sql = sqlite3_sql(st); + if (!g_delete_on_qb_step || !g_delete_db) return; + if (!sql || strstr(sql, "MIN(id), MAX(running)") == NULL) return; + g_delete_on_qb_step = 0; + sqlite3_exec(g_delete_db, + "DELETE FROM shares WHERE id = (SELECT MIN(id) FROM shares)", + NULL, NULL, NULL); +} + +static void test_a_row_deleted_during_the_walk_still_terminates(void) { + const char *path = fresh_path(); + store_t *s = open_with_shares(path, 50, 10.0); /* 500 total difficulty */ + + store_window_entry_t win[16]; + size_t n = 0; double total = 0.0; int trunc = 0; char err[256] = {0}; + + g_delete_db = s->db; + g_delete_on_qb_step = 1; + /* A window far wider than the history, so "covered" can never end the + * walk and only the end-of-table test can. */ + int rc = store_pplns_window(s, 1e9, win, 16, &n, &total, &trunc, err, sizeof err); + g_delete_on_qb_step = 0; g_delete_db = NULL; + + /* It must come back -- with the remaining history, or with an error. What + * it must not do is spin. */ + assert(rc >= 0); + assert(n > 0); + assert(total > 0.0 && total < 1e9); + store_close(s); + unlink(path); + printf(" ok test_a_row_deleted_during_the_walk_still_terminates" + " (rc=%d n=%zu total=%.0f)\n", rc, n, total); +} + +/* A pool younger than its own window is NOT an error -- it is the documented + * case and must still pay across everything it has. Conflating it with a + * truncated read would take a new pool offline on day one. */ + +static void test_a_pool_younger_than_its_window_still_returns_a_window(void) { + const char *path = fresh_path(); + store_t *s = open_with_shares(path, 20, 10.0); + g_fail_qb_after = -1; + + store_window_entry_t win[16]; + size_t n = 0; double total = 0.0; int trunc = 0; char err[256] = {0}; + int rc = store_pplns_window(s, 1e9, win, 16, &n, &total, &trunc, err, sizeof err); + assert(rc > 0); + assert(n > 0); + assert(total > 0.0 && total < 1e9); + store_close(s); + unlink(path); + printf(" ok test_a_pool_younger_than_its_window_still_returns_a_window\n"); +} + +static void test_an_empty_table_is_not_an_error(void) { + const char *path = fresh_path(); + store_t *s = open_with_shares(path, 0, 10.0); + g_fail_qb_after = -1; + + store_window_entry_t win[4]; + size_t n = 999; double total = -1.0; int trunc = 0; char err[256] = {0}; + int rc = store_pplns_window(s, 500.0, win, 4, &n, &total, &trunc, err, sizeof err); + assert(rc == 0); + assert(n == 0); + assert(total == 0.0); + store_close(s); + unlink(path); + printf(" ok test_an_empty_table_is_not_an_error\n"); +} + +/* The seam must fail the boundary statement and NOTHING else. + * + * An earlier attempt at this suite injected through a global interrupt budget, + * which killed the payout query too -- so its tests went red for a reason that + * had nothing to do with the walk, and would have gone red against the fixed + * code as well. Every other test here returns before the payout query is + * reached, so none of them can catch a matcher that is too wide. This one arms + * the injection with a budget the walk never spends: if the matcher caught the + * payout query, its steps would spend that budget and be interrupted. */ +static void test_the_injection_seam_does_not_reach_the_payout_query(void) { + const char *path = fresh_path(); + store_t *s = open_with_shares(path, 200, 10.0); + /* Armed, but the walk covers 500 in one step and the budget is five. */ + g_fail_qb_after = 5; + g_qb_steps = 0; + + store_window_entry_t win[16]; + size_t n = 0; double total = 0.0; int trunc = 0; char err[256] = {0}; + int rc = store_pplns_window(s, 500.0, win, 16, &n, &total, &trunc, err, sizeof err); + assert(rc > 0); + assert(n > 0); + assert(total >= 500.0 && total <= 510.0); + /* One statement was ever matched, and it stepped once: the boundary query. + * The payout query ran through the same wrapper and was left alone. */ + assert(g_qb_steps == 1); + store_close(s); + unlink(path); + printf(" ok test_the_injection_seam_does_not_reach_the_payout_query" + " (qb_steps=%d)\n", g_qb_steps); +} + +int main(void) { + test_the_walk_serves_the_configured_window_when_nothing_fails(); + test_a_walk_that_fails_immediately_does_not_serve_the_whole_table(); + test_a_walk_that_fails_after_widening_does_not_serve_a_short_window(); + test_a_row_deleted_during_the_walk_still_terminates(); + test_a_pool_younger_than_its_window_still_returns_a_window(); + test_an_empty_table_is_not_an_error(); + test_the_injection_seam_does_not_reach_the_payout_query(); + printf("test_store_walk: all tests passed\n"); + return 0; +} diff --git a/tests/test_store_walk.mk b/tests/test_store_walk.mk new file mode 100644 index 0000000..f442b27 --- /dev/null +++ b/tests/test_store_walk.mk @@ -0,0 +1,8 @@ +# White-box: includes src/store.c as source (fault injection on sqlite3_step), +# so it does NOT link store.c the way the other suites do. +test_store_walk_bin = build/test_store_walk +$(test_store_walk_bin): tests/test_store_walk.c src/store.c src/log.c + mkdir -p build + $(CC) $(CFLAGS) -Wno-unused-function -Isrc -o $(test_store_walk_bin) tests/test_store_walk.c src/log.c -lsqlite3 -lpthread +test_store_walk: $(test_store_walk_bin) + ./$(test_store_walk_bin) From 439a45518fee3fc62cf5308e7d729d337b3ddeec Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 10 Sep 2026 10:02:52 +0200 Subject: [PATCH 24/36] pplns window: pin both ends of the walk, and close #81's leftovers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wired4ncer's #81 is merged as-is; this finishes the two things his review raised but his PR deliberately left alone, plus the wiring. ## Shares landing mid-walk were swept into the window The boundary search and the payout query are two statements in two implicit read transactions, so a bare `sh.id >= cutoff` paid out work that arrived AFTER the window was measured. The single statement they replaced was atomic for free; nothing had replaced that property, and nothing said so. Measured with 50 shares committed between the two: 550 difficulty served against a configured 500, and it grows with the share rate. Small, but it means blocks_found.pplns_window_diff records a window the block did not actually pay across. The boundary query already reads the rows, so it now reports MAX(id) too and the payout query is bounded at both ends. The two statements describe exactly the same rows without needing a transaction. Verified at 500 exactly with the same 50 shares landing in between, and the test fails at 550 if the upper bound is removed. ## The comments said "doubling" and the code multiplied by four He fixed store.c; the same claim was in four more places in test_store.c, along with a reference to a cap that does not exist. Corrected rather than left to mislead the next person reading the loop. ## Wiring test_store_walk was in `test` and `asan` but not `coverage`; added. tests/ README.md now explains why that suite is white-box — it includes store.c as source to inject a sqlite failure part-way through the widening loop, because a progress handler also fires for the store's commit thread on the same connection and cannot single out one statement. Verified: full C suite, ASan clean, and the solo, pps-classic, pplns and pplns-coinbase e2e suites. --- Makefile | 4 ++- src/store.c | 21 ++++++++++-- tests/README.md | 15 +++++++++ tests/test_store.c | 13 +++++--- tests/test_store_walk.c | 74 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 68c2586..977c85f 100644 --- a/Makefile +++ b/Makefile @@ -215,6 +215,8 @@ coverage: $(CC) $(COV_CFLAGS) -o $(COV_DIR)/test_thunder tests/test_thunder.c src/thunder.c $(CC) $(COV_CFLAGS) -o $(COV_DIR)/test_config tests/test_config.c \ src/config.c src/log.c src/coinbase.c src/sha256.c + $(CC) $(COV_CFLAGS) -Wno-unused-function -o $(COV_DIR)/test_store_walk \ + tests/test_store_walk.c src/log.c -lsqlite3 -lpthread $(CC) $(COV_CFLAGS) -o $(COV_DIR)/test_pplns tests/test_pplns.c \ src/pplns.c src/coinbase.c src/sha256.c $(CC) $(COV_CFLAGS) -o $(COV_DIR)/test_reconcile tests/test_reconcile.c \ @@ -229,7 +231,7 @@ coverage: $(addprefix -object ,$(COV_DIR)/test_store $(COV_DIR)/test_coinbase \ $(COV_DIR)/test_share $(COV_DIR)/test_bitcoind $(COV_DIR)/test_broadcast \ $(COV_DIR)/test_thunder $(COV_DIR)/test_config $(COV_DIR)/test_reconcile \ - $(COV_DIR)/test_pplns) \ + $(COV_DIR)/test_pplns $(COV_DIR)/test_store_walk) \ -instr-profile=$(COV_DIR)/all.profdata $(COV_IGNORE) format: diff --git a/src/store.c b/src/store.c index 47d5d8f..c1f37ba 100644 --- a/src/store.c +++ b/src/store.c @@ -1486,13 +1486,28 @@ int store_pplns_window(store_t *s, double window_diff, * matching store_pplns_distribute() exactly. If these two ever disagree a * block pays out differently from what its template promised. */ static const char *QB = - "SELECT MIN(id), MAX(running), COUNT(*) FROM (" + "SELECT MIN(id), MAX(running), COUNT(*), MAX(id) FROM (" " SELECT id, difficulty," " SUM(difficulty) OVER (ORDER BY id DESC ROWS UNBOUNDED PRECEDING) AS running" " FROM (SELECT id, difficulty FROM shares ORDER BY id DESC LIMIT ?)" ") WHERE running - difficulty < ?"; sqlite3_int64 cutoff_id = 0; + /* The newest row the boundary search actually read. + * + * The payout query below is a second statement in a second implicit read + * transaction, so shares committed between the two would be swept in by a + * bare `sh.id >= cutoff` and paid out of this block -- work that arrived + * after the window was measured. Measured at 550 difficulty served against + * a configured 500 with 50 shares landing mid-walk, and it grows with the + * share rate. + * + * Pinning the top as well makes the two statements describe exactly the + * same rows, which is what the single statement they replaced did for + * free. INT64_MAX so an empty table -- the one path that never assigns it + * -- still produces a well-formed query rather than an empty range. + * (Raised by Wired4ncer on #81.) */ + sqlite3_int64 top_id = INT64_MAX; { sqlite3_stmt *b = NULL; if (sqlite3_prepare_v2(s->db, QB, -1, &b, NULL) != SQLITE_OK) { @@ -1538,6 +1553,7 @@ int store_pplns_window(store_t *s, double window_diff, /* No shares at all: no work, no window, no payees. Not an error. */ if (got == 0) { settled = 1; break; } cutoff_id = sqlite3_column_int64(b, 0); + top_id = sqlite3_column_int64(b, 3); double covered = sqlite3_column_double(b, 1); /* Covered means the batch reached past the window. */ if (covered >= window_diff) { settled = 1; break; } @@ -1571,7 +1587,7 @@ int store_pplns_window(store_t *s, double window_diff, " FROM shares sh " " JOIN workers w ON w.id = sh.worker_id " " LEFT JOIN pplns_fractions f ON f.worker_id = w.id " - " WHERE sh.id >= ? " + " WHERE sh.id >= ? AND sh.id <= ? " " AND w.payout_address IS NOT NULL AND w.payout_address <> '' " " GROUP BY w.id " " HAVING wd > 0 " @@ -1584,6 +1600,7 @@ int store_pplns_window(store_t *s, double window_diff, return -2; } sqlite3_bind_int64(st, 1, cutoff_id); + sqlite3_bind_int64(st, 2, top_id); size_t n = 0; double total = 0.0; diff --git a/tests/README.md b/tests/README.md index 63472fb..caa5df5 100644 --- a/tests/README.md +++ b/tests/README.md @@ -17,6 +17,21 @@ test, sitting in a `static` function inside `main.c`: difficulty is clamped to network difficulty), so a chain cannot produce a window of wildly different claim sizes without help. +One is white-box rather than linked: + +- `test_store_walk.c` — the PPLNS boundary walk's ERROR paths. It `#include`s + `src/store.c` as source with `sqlite3_step` redirected, so a sqlite failure + can be injected part-way through the widening loop and production code + carries no test seam. A progress handler was no good: it also fires for the + store's commit thread on the same connection, so it cannot single out one + statement. + + What it pins is that a walk which cannot PROVE it covered the window returns + an error rather than a window. Before that fix, an injected failure served a + window 40x too wide on one path and 12x too narrow on another, both with a + success code — and in `pplns-coinbase` a wrong window is mined into a + coinbase and published, so nothing downstream can notice. + `make asan` runs a subset under AddressSanitizer + UBSan; `make coverage` reports line and function coverage of the unit suites only. diff --git a/tests/test_store.c b/tests/test_store.c index 32cb9a3..4793866 100644 --- a/tests/test_store.c +++ b/tests/test_store.c @@ -1502,10 +1502,13 @@ static void test_the_payout_floor_is_published_for_the_dashboard(void) { * thread. A production pool reported a 5.5 GB database, where that is half a * minute per template and the pool simply stops publishing work. * - * The replacement walks back in bounded batches, doubling until the batch - * covers the window. These tests exist for the doubling, because that is the + * The replacement walks back in bounded batches, growing x4 until the batch + * covers the window. These tests exist for that widening, because it is the * part that can silently return a PARTIAL window -- which would not error, it - * would just pay the wrong people. */ + * would just pay the wrong people. + * + * The error paths through the same loop are covered separately, in + * tests/test_store_walk.c, which injects sqlite failures. */ static void test_the_window_reads_past_the_first_batch(void) { const char *path = fresh_db_path(); store_cfg_t cfg = {0}; @@ -1545,13 +1548,13 @@ static void test_the_window_reads_past_the_first_batch(void) { err, sizeof err) > 0); assert(total == 1000.0); - /* Past it — this is the case the doubling exists for. */ + /* Past it — this is the case the widening exists for. */ assert(store_pplns_window(s, 6000.0, win, 16, &n, &total, &truncated, err, sizeof err) > 0); assert(total == 6000.0); /* Wider than the entire history: every share, and no infinite loop - * doubling past the end of the table. */ + * growing past the end of the table. */ assert(store_pplns_window(s, 999999.0, win, 16, &n, &total, &truncated, err, sizeof err) > 0); assert(total == 10000.0); diff --git a/tests/test_store_walk.c b/tests/test_store_walk.c index d6db9f2..be6d26b 100644 --- a/tests/test_store_walk.c +++ b/tests/test_store_walk.c @@ -23,6 +23,7 @@ static int tsw_step(sqlite3_stmt *st); static void maybe_delete_oldest(sqlite3_stmt *st); +static void maybe_insert_behind_the_walk(sqlite3_stmt *st); /* Redirect every sqlite3_step() inside store.c to the wrapper below. */ #define sqlite3_step tsw_step @@ -47,6 +48,7 @@ static int tsw_step(sqlite3_stmt *st) { if (is_qb && g_fail_qb_after >= 0 && g_qb_steps++ >= g_fail_qb_after) return SQLITE_INTERRUPT; maybe_delete_oldest(st); + maybe_insert_behind_the_walk(st); return sqlite3_step(st); } @@ -284,6 +286,77 @@ static void test_the_injection_seam_does_not_reach_the_payout_query(void) { " (qb_steps=%d)\n", g_qb_steps); } +/* Shares landing between the boundary search and the payout query must NOT be + * swept into this window. + * + * They are two statements in two implicit read transactions, so a bare + * `sh.id >= cutoff` pays out work that arrived after the window was measured: + * 550 difficulty served against a configured 500, with 50 shares landing in + * between, growing with the share rate. The single statement they replaced was + * atomic for free. The boundary query now reports MAX(id) as well, so the + * payout query is bounded at both ends and describes exactly the rows the walk + * read. (Raised by Wired4ncer on #81.) + * + * Injected on the PAYOUT query's step rather than the boundary query's, + * because "between the two" is precisely where the rows have to land. */ +static sqlite3 *g_side = NULL; +static int g_insert_behind = 0; + +static void maybe_insert_behind_the_walk(sqlite3_stmt *st) { + const char *sql = sqlite3_sql(st); + /* The payout query: joins workers and takes the id range. The boundary + * query has neither. */ + if (!g_insert_behind || !sql) return; + if (!strstr(sql, "JOIN workers") || !strstr(sql, "sh.id >= ?")) return; + g_insert_behind = 0; + for (int i = 0; i < 50; ++i) + assert(sqlite3_exec(g_side, + "INSERT INTO shares (worker_id,ts,difficulty) VALUES (1,9999,1.0)", + NULL, NULL, NULL) == SQLITE_OK); +} + +static void test_shares_landing_mid_walk_are_not_swept_in(void) { + const char *path = fresh_path(); + store_t *s = open_with_shares(path, 5000, 1.0); + assert(sqlite3_open(path, &g_side) == SQLITE_OK); + sqlite3_busy_timeout(g_side, 5000); + + store_window_entry_t win[16]; + size_t n = 0; double total = 0; int tr = 0; char err[256] = {0}; + + /* Quiet pool: exactly the configured window. */ + g_insert_behind = 0; + assert(store_pplns_window(s, 500.0, win, 16, &n, &total, &tr, + err, sizeof err) > 0); + assert(total == 500.0); + + /* Same window, with 50 shares committed between the two statements. */ + g_insert_behind = 1; + assert(store_pplns_window(s, 500.0, win, 16, &n, &total, &tr, + err, sizeof err) > 0); + if (total != 500.0) { + printf("FAIL: shares landed mid-walk and %.0f difficulty was served " + "against a configured 500\n", total); + exit(1); + } + assert(g_insert_behind == 0); /* the injection really fired */ + + /* And they really were committed, so this did not pass for the wrong + * reason. */ + sqlite3_stmt *c = NULL; + assert(sqlite3_prepare_v2(g_side, "SELECT COUNT(*) FROM shares", -1, &c, + NULL) == SQLITE_OK); + assert(sqlite3_step(c) == SQLITE_ROW); + sqlite3_int64 rows = sqlite3_column_int64(c, 0); + sqlite3_finalize(c); + assert(rows == 5050); + + sqlite3_close(g_side); g_side = NULL; + store_close(s); + printf(" ok test_shares_landing_mid_walk_are_not_swept_in " + "(%lld rows in the table, window still 500)\n", (long long)rows); +} + int main(void) { test_the_walk_serves_the_configured_window_when_nothing_fails(); test_a_walk_that_fails_immediately_does_not_serve_the_whole_table(); @@ -292,6 +365,7 @@ int main(void) { test_a_pool_younger_than_its_window_still_returns_a_window(); test_an_empty_table_is_not_an_error(); test_the_injection_seam_does_not_reach_the_payout_query(); + test_shares_landing_mid_walk_are_not_swept_in(); printf("test_store_walk: all tests passed\n"); return 0; } From 1dd77e2266085ebaf3e37ea9c17eaf86f8de2c92 Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 10 Sep 2026 10:22:31 +0200 Subject: [PATCH 25/36] store: use savepoints, because one connection is shared by three threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught the mixed-window e2e failing on a stage that had passed locally minutes before. The cause was a race, and a data-losing one: WARN pplns-coinbase: could not record the payout queue for block 36173cc41b9844f7: cannot start a transaction within a transaction — rotation for this block is lost The store keeps ONE sqlite connection and shares it between the commit thread, the tip watcher and the stratum submit path. BEGIN IMMEDIATE fails outright when another thread is already mid-transaction, so whether a write lands depends on where the commit thread happens to be. The block was paid correctly — that is the coinbase, already on chain — but the record of whom it skipped was dropped, so the miner it skipped never moved up the queue. A WARN, and nothing else. Three functions had it. store_stage_block_fractions and store_settle_block_fractions are mine. store_pplns_distribute has had it since before this branch and was surviving on being retried every tip, which is why it never looked like a bug. All three now use SAVEPOINT, which nests: with no transaction open it starts one, inside another it is a nested unit that RELEASE folds into the outer commit. Either way the caller still gets all-or-nothing. Pinned by a test that puts the shared connection into a transaction first — the exact state the commit thread leaves it in — so the nesting is exercised deterministically instead of by luck. Restoring BEGIN IMMEDIATE fails the store suite. Worth noting what this says about the earlier green runs: the same mutation also fails test_only_a_confirmed_block_moves_the_queue, which has been passing since the payout queue landed. It was racing all along and winning. Also fixes the e2e's own blind spot — the mixed-window stage runs a second pool with its own log, and dump_logs only ever tailed the first one, so the failure arrived with no diagnostics attached. It now dumps that log and prints what the pool said about the block. Verified: full C suite, ASan, five regtest e2e suites, both node suites. --- src/store.c | 67 +++++++++++++++++++++++----- src/store.h | 6 +++ tests/test_pplns_coinbase_regtest.sh | 8 +++- tests/test_store.c | 62 +++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 12 deletions(-) diff --git a/src/store.c b/src/store.c index c1f37ba..65158ff 100644 --- a/src/store.c +++ b/src/store.c @@ -1271,6 +1271,40 @@ int store_record_block(store_t *s, uint64_t ts_ms, int height, /* ---- PPLNS distribution ------------------------------------------------ */ +/* Transactions that may already be inside one. + * + * The store keeps ONE sqlite connection and shares it: the commit thread + * batches shares on it, while the tip watcher and the stratum submit path also + * write through it. BEGIN IMMEDIATE fails outright when the commit thread + * happens to be mid-batch -- "cannot start a transaction within a transaction" + * -- which is a race, so it shows up as an occasional lost write rather than + * as anything reproducible. + * + * SAVEPOINT nests. With no transaction open it starts one; inside another it + * is a nested unit that RELEASE folds into the outer commit. Either way the + * caller gets all-or-nothing, which is the property these writes actually + * need. + * + * Found when the coinbase-direct payout queue silently dropped a block's + * rotation under load (LayerTwo-Labs/simplepool#76). store_pplns_distribute() + * had the same bug and had been surviving on being retried each tip. */ +static int sp_begin(store_t *s, const char *name) { + char q[64]; + snprintf(q, sizeof q, "SAVEPOINT %s", name); + return sqlite3_exec(s->db, q, NULL, NULL, NULL); +} +static void sp_release(store_t *s, const char *name) { + char q[64]; + snprintf(q, sizeof q, "RELEASE %s", name); + sqlite3_exec(s->db, q, NULL, NULL, NULL); +} +static void sp_rollback(store_t *s, const char *name) { + char q[96]; + snprintf(q, sizeof q, "ROLLBACK TO %s", name); + sqlite3_exec(s->db, q, NULL, NULL, NULL); + sp_release(s, name); +} + int store_pplns_distribute(store_t *s, int maturity_confs, int fee_bps, int *out_blocks, int *out_workers, char *errbuf, size_t errlen) @@ -1367,7 +1401,7 @@ int store_pplns_distribute(store_t *s, int maturity_confs, int fee_bps, * 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) { + if (sp_begin(s, "sp_dist") != SQLITE_OK) { rc_out = -1; break; } @@ -1411,7 +1445,7 @@ int store_pplns_distribute(store_t *s, int maturity_confs, int fee_bps, sqlite3_finalize(mark); if (ok) { - sqlite3_exec(s->db, "COMMIT", NULL, NULL, NULL); + sp_release(s, "sp_dist"); blocks++; workers += credited_here; LOG_INFO("pplns: block %.16s… distributed %lld sats of %lld across " @@ -1419,7 +1453,7 @@ int store_pplns_distribute(store_t *s, int maturity_confs, int fee_bps, hbuf, (long long)distributed, (long long)payable, credited_here, window); } else { - sqlite3_exec(s->db, "ROLLBACK", NULL, NULL, NULL); + sp_rollback(s, "sp_dist"); if (errbuf && errlen) snprintf(errbuf, errlen, "distribute %.16s: %s", hbuf, sqlite3_errmsg(s->db)); @@ -1652,14 +1686,14 @@ int store_stage_block_fractions(store_t *s, const char *block_hash, "VALUES (?, ?, ?) " "ON CONFLICT(block_hash, worker_id) DO UPDATE SET delta = excluded.delta"; - if (sqlite3_exec(s->db, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) { + if (sp_begin(s, "sp_stage") != SQLITE_OK) { if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); return -1; } sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(s->db, Q, -1, &st, NULL) != SQLITE_OK) { if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); - sqlite3_exec(s->db, "ROLLBACK", NULL, NULL, NULL); + sp_rollback(s, "sp_stage"); atomic_fetch_add(&s->pg_errors, 1); return -2; } @@ -1676,11 +1710,11 @@ int store_stage_block_fractions(store_t *s, const char *block_hash, sqlite3_finalize(st); if (!ok) { if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); - sqlite3_exec(s->db, "ROLLBACK", NULL, NULL, NULL); + sp_rollback(s, "sp_stage"); atomic_fetch_add(&s->pg_errors, 1); return -2; } - sqlite3_exec(s->db, "COMMIT", NULL, NULL, NULL); + sp_release(s, "sp_stage"); return wrote; } @@ -1698,7 +1732,7 @@ int store_settle_block_fractions(store_t *s, int *out_applied, /* One transaction for the whole settlement. A partially applied block * would leave the ledger not summing to zero, and unlike a failed payout * there is no later pass that could notice: the pending rows are gone. */ - if (sqlite3_exec(s->db, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) { + if (sp_begin(s, "sp_settle") != SQLITE_OK) { if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); return -1; } @@ -1719,7 +1753,7 @@ int store_settle_block_fractions(store_t *s, int *out_applied, sqlite3_stmt *sel = NULL; if (sqlite3_prepare_v2(s->db, ONE_HASH, -1, &sel, NULL) != SQLITE_OK) { if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); - sqlite3_exec(s->db, "ROLLBACK", NULL, NULL, NULL); + sp_rollback(s, "sp_settle"); return -2; } while (nh < 64 && sqlite3_step(sel) == SQLITE_ROW) { @@ -1764,16 +1798,27 @@ int store_settle_block_fractions(store_t *s, int *out_applied, if (!ok) { if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); - sqlite3_exec(s->db, "ROLLBACK", NULL, NULL, NULL); + sp_rollback(s, "sp_settle"); atomic_fetch_add(&s->pg_errors, 1); return -2; } - sqlite3_exec(s->db, "COMMIT", NULL, NULL, NULL); + sp_release(s, "sp_settle"); if (out_applied) *out_applied = applied; if (out_discarded) *out_discarded = discarded; return 0; } +int store_begin_txn_for_test(store_t *s) { + if (!s || !s->db) return -1; + return sqlite3_exec(s->db, "BEGIN IMMEDIATE", NULL, NULL, NULL) == SQLITE_OK + ? 0 : -1; +} + +int store_end_txn_for_test(store_t *s) { + if (!s || !s->db) return -1; + return sqlite3_exec(s->db, "COMMIT", NULL, NULL, NULL) == SQLITE_OK ? 0 : -1; +} + 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 f8d471a..fa7bb95 100644 --- a/src/store.h +++ b/src/store.h @@ -263,6 +263,12 @@ int store_settle_block_fractions(store_t *s, int *out_applied, int *out_discarded, char *errbuf, size_t errlen); +/* Test hooks: put the store's shared connection into a transaction, the way + * the commit thread does mid-batch, so a nested write can be exercised without + * racing a real one. Not for production use. */ +int store_begin_txn_for_test(store_t *s); +int store_end_txn_for_test(store_t *s); + 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/tests/test_pplns_coinbase_regtest.sh b/tests/test_pplns_coinbase_regtest.sh index d84dc5c..d67f4a4 100755 --- a/tests/test_pplns_coinbase_regtest.sh +++ b/tests/test_pplns_coinbase_regtest.sh @@ -84,7 +84,7 @@ stage() { echo; echo "=== cbwin-e2e: $1"; } dump_logs() { echo "!!! cbwin-e2e FAILED — recent logs:" >&2 - for f in "$REGTEST_DIR"/logs/*.log "$POOL_LOG"; do + for f in "$REGTEST_DIR"/logs/*.log "$POOL_LOG" /tmp/simplepool-cbmix.log; do [ -f "$f" ] || continue echo "--- tail $f" >&2 tail -40 "$f" >&2 @@ -569,6 +569,12 @@ FROWS="$(sqlite3 "$MIX_DB" "SELECT COUNT(*) FROM pplns_fractions")" echo " staged=$QROWS applied=$FROWS" [ "$QROWS" -ge 1 ] || [ "$FROWS" -ge 1 ] || { echo "FAIL: a block skipped a miner but nothing was recorded in the queue" >&2 + echo "--- what the pool said about this block:" >&2 + grep -E "pplns-coinbase: (block|staged|could not)" "$MIX_LOG" | tail -10 >&2 + echo "--- window and payee state:" >&2 + grep -oE "window of [0-9]+ miner\(s\)[^\"]*" "$MIX_LOG" | tail -3 >&2 + echo "--- blocks recorded:" >&2 + sqlite3 "$MIX_DB" "SELECT height, substr(hash,1,16), status FROM blocks_found" >&2 exit 1; } # Zero-sum, across both the staged rows and any already applied. Rounded to diff --git a/tests/test_store.c b/tests/test_store.c index 4793866..0016e39 100644 --- a/tests/test_store.c +++ b/tests/test_store.c @@ -1718,6 +1718,67 @@ static void test_the_window_reports_each_workers_standing(void) { printf(" ok test_the_window_reports_each_workers_standing\n"); } +/* Writes that land while the commit thread holds a transaction. + * + * The store keeps ONE sqlite connection and shares it between the commit + * thread, the tip watcher and the stratum submit path. BEGIN IMMEDIATE fails + * outright when another thread is already mid-transaction — "cannot start a + * transaction within a transaction" — and because it is a race it shows up as + * an occasional lost write rather than as anything reproducible. + * + * It cost a real one: a block's payout-queue rotation was dropped with only a + * WARN, so the miner it skipped never moved up the queue. Caught by the + * regtest e2e, which had passed on the same code minutes earlier. + * + * Simulated deterministically here by opening a transaction on the store's own + * connection first, which is exactly the state the commit thread leaves it in. + * SAVEPOINT nests where BEGIN cannot. */ +static void test_writes_nest_inside_an_open_transaction(void) { + const char *path = fresh_db_path(); + store_cfg_t cfg = {0}; + snprintf(cfg.path, sizeof(cfg.path), "%s", path); + store_t *s = NULL; + assert(store_open(&cfg, &s) == 0); + char err[256] = {0}; + + sqlite3 *db = NULL; + assert(sqlite3_open(path, &db) == SQLITE_OK); + sqlite3_exec(db, "INSERT INTO workers (id,name,payout_address,first_seen,last_seen)" + " VALUES (1,'a','bc1qa',1,1),(2,'b','bc1qb',1,1)", NULL, NULL, NULL); + sqlite3_exec(db, "INSERT INTO blocks_found (ts,height,hash,reward_sats,fee_sats,status)" + " VALUES (1,10,'h1',100,1,'confirmed')", NULL, NULL, NULL); + sqlite3_close(db); + + /* Put the shared connection in the state the commit thread leaves it in. */ + assert(store_begin_txn_for_test(s) == 0); + + store_fraction_delta_t d[] = { {1, 0.25}, {2, -0.25} }; + int rc = store_stage_block_fractions(s, "h1", d, 2, err, sizeof err); + if (rc < 0) { + printf("FAIL: staging inside an open transaction failed: %s\n", err); + assert(0 && "a nested write must not be refused"); + } + assert(rc == 2); + + int applied = 0, discarded = 0; + assert(store_settle_block_fractions(s, &applied, &discarded, + err, sizeof err) == 0); + assert(applied == 1); + + assert(store_end_txn_for_test(s) == 0); + + /* And it really committed, rather than being rolled back with the outer. */ + assert(sqlite3_open(path, &db) == SQLITE_OK); + char buf[64]; + scalar_text(db, "SELECT CAST(ROUND(owed_fraction*100) AS INT) " + "FROM pplns_fractions WHERE worker_id=1", buf, sizeof buf); + assert(strcmp(buf, "25") == 0); + sqlite3_close(db); + + store_close(s); + printf(" ok test_writes_nest_inside_an_open_transaction\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(); @@ -1776,6 +1837,7 @@ int main(void) { test_fraction_deltas_must_sum_to_zero(); test_only_a_confirmed_block_moves_the_queue(); test_two_confirmed_blocks_both_count(); + test_writes_nest_inside_an_open_transaction(); test_the_window_reports_each_workers_standing(); test_pplns_distributes_two_blocks_in_one_pass(); test_an_empty_window_returns_nothing_not_an_error(); From d69251ba5492806709d73387b37c63ddc5d3fc77 Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 10 Sep 2026 11:10:56 +0200 Subject: [PATCH 26/36] Cover the two payment paths that were fixed but unverified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were coverage gaps on code that decides what miners are paid, and both were the same shape: a fix landed, the suite went green, and nothing actually exercised the fixed path. ## The payout queue was only ever proved to STAGE The e2e asserted rows appear in pplns_pending_fractions. It never checked they are applied, because the block was still pending when it looked — so the assertion passed on staging alone and would have passed with settling completely broken. That is the same vacuous-stage shape this file was caught with once before, where a stage printed "carried: 0 sats" and passed regardless. It now mines one more block so the confirmation pass has something to decide with, and asserts the transition: before: staged only, applied=0 (the block is still pending) after: confirmed_blocks=1 applied_rows=3 the applied queue still sums to zero ## store_pplns_distribute's savepoint was never driven nested The previous commit fixed three functions that opened BEGIN IMMEDIATE on the shared connection, but the nesting test only covered two. distribute's savepoint is inside its per-block loop, so reaching it needs a matured block with a window — the test now builds one and drives distribute inside an open transaction. Restoring BEGIN IMMEDIATE there fails the suite. Verified: full C suite, ASan, and the solo, pplns and pplns-coinbase e2e suites. --- tests/test_pplns_coinbase_regtest.sh | 47 ++++++++++++++++++++++++++++ tests/test_store.c | 32 +++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/tests/test_pplns_coinbase_regtest.sh b/tests/test_pplns_coinbase_regtest.sh index d67f4a4..c545e80 100755 --- a/tests/test_pplns_coinbase_regtest.sh +++ b/tests/test_pplns_coinbase_regtest.sh @@ -600,6 +600,53 @@ OWED="$(sqlite3 "$MIX_DB" "SELECT CAST(ROUND(COALESCE(( exit 1; } echo " the skipped miner is owed $OWED/1000 of a block reward, and is next in line" +stage "assert the queue is APPLIED once the block confirms, not before" +# Everything above proves rows were STAGED. Staging is the easy half: the +# rotation only becomes real when the confirmation pass applies it, and until +# then a block that gets orphaned must rotate nobody. +# +# This stage exists because the assertion above passes on staging alone — it +# would still pass if settling never worked at all, which is exactly the shape +# of vacuous stage this file has been caught with once before. +BEFORE_APPLIED="$(sqlite3 "$MIX_DB" "SELECT COUNT(*) FROM pplns_fractions")" +[ "$BEFORE_APPLIED" = "0" ] || { + echo "FAIL: the queue was applied while the block was still pending" >&2 + exit 1; } +echo " before: staged only, applied=0 (the block is still pending)" + +# A block is confirmed once a template at height+1 is seen building on it, so +# one more block gives the confirmation pass something to decide with. +NEXT=$(( $(cli getblockcount) + 1 )) +for _ in $(seq 1 40); do + grep -q "new job: height=${NEXT} " "$MIX_LOG" && break + sleep 1 +done +node "$ROOT/scripts/regtest/cpuminer.js" --port "$POOL_PORT" --user "$MINER_ADDR" --timeout 180 >/dev/null 2>&1 || true + +for _ in $(seq 1 40); do + APPLIED="$(sqlite3 "$MIX_DB" "SELECT COUNT(*) FROM pplns_fractions")" + [ "${APPLIED:-0}" -gt 0 ] && break + sleep 1 +done +CONF="$(sqlite3 "$MIX_DB" "SELECT COUNT(*) FROM blocks_found WHERE status='confirmed'")" +echo " after: confirmed_blocks=$CONF applied_rows=${APPLIED:-0}" +[ "${APPLIED:-0}" -gt 0 ] || { + echo "FAIL: a block confirmed but the payout queue was never applied — the" >&2 + echo " rotation is staged forever and nobody's turn ever comes" >&2 + sqlite3 "$MIX_DB" "SELECT height, substr(hash,1,16), status FROM blocks_found" >&2 + grep -E "payout queue settled|could not settle" "$MIX_LOG" | tail -5 >&2 + exit 1; } + +# Still zero-sum after applying, and the staged rows for the applied block are +# gone rather than applied twice. +BAL2="$(sqlite3 "$MIX_DB" "SELECT CAST(ROUND(( + COALESCE((SELECT SUM(delta) FROM pplns_pending_fractions),0) + + COALESCE((SELECT SUM(owed_fraction) FROM pplns_fractions),0)) * 1000000) AS INT)")" +[ "$BAL2" = "0" ] || { + echo "FAIL: after applying, the queue sums to $BAL2 (x1e-6), not zero" >&2 + exit 1; } +echo " the applied queue still sums to zero" + echo echo "cbwin-e2e: PASS (the window was paid from the block's own coinbase," echo " and the pool never held the reward)" diff --git a/tests/test_store.c b/tests/test_store.c index 0016e39..c0416c2 100644 --- a/tests/test_store.c +++ b/tests/test_store.c @@ -1767,6 +1767,38 @@ static void test_writes_nest_inside_an_open_transaction(void) { assert(store_end_txn_for_test(s) == 0); + /* store_pplns_distribute() has the same shape and had the same bug — it + * was surviving on being retried every tip, which is why it never looked + * like one. Its savepoint only runs when there is a matured block to + * distribute, so give it one and drive it nested too. */ + assert(store_record_share_addr(s, "alice", "addr_a", 2000, 50.0, + 0, NULL, 0, 0.0) == 0); + assert(store_record_share_addr(s, "bob", "addr_b", 2001, 50.0, + 0, NULL, 0, 0.0) == 0); + /* The block-finding share the distributor anchors its window on, at + * difficulty 0 so it does not shift the split. */ + assert(store_record_share_addr(s, "alice", "addr_a", 2002, 0.0, + 1, "blk_nest", 0, 0.0) == 0); + assert(store_record_block(s, 3000, 800100, "blk_nest", "alice", "addr_a", + 90000, 10000, STORE_BLOCK_PENDING, NULL, + 100.0) == 0); + assert(store_flush(s) == 0); + assert(store_set_block_status(s, "blk_nest", STORE_BLOCK_CONFIRMED, + 100, "node") == 0); + + assert(store_begin_txn_for_test(s) == 0); + int nblocks = 0, nworkers = 0; + char derr[256] = {0}; + int drc = store_pplns_distribute(s, 100, 0, &nblocks, &nworkers, + derr, sizeof derr); + if (drc < 0) { + printf("FAIL: distribute inside an open transaction failed: %s\n", derr); + assert(0 && "a nested distribute must not be refused"); + } + assert(nblocks == 1); + assert(nworkers == 2); + assert(store_end_txn_for_test(s) == 0); + /* And it really committed, rather than being rolled back with the outer. */ assert(sqlite3_open(path, &db) == SQLITE_OK); char buf[64]; From a5aebf995eb902a7abfff37755ca0ae43970bb7e Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 10 Sep 2026 11:24:33 +0200 Subject: [PATCH 27/36] Release notes for 0.4.0, and finish the docs sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## CHANGELOG.md, published by the release job The release workflow emitted install boilerplate and nothing else, so what changed in a version lived only in 83 commit messages. CHANGELOG.md now holds it, and the job extracts the section matching the tag and publishes it above the boilerplate — notes get reviewed in the PR that writes them, for the same reason the binary is built in CI: a release note pasted into the web UI traces back to nothing. A tag with no section still releases, with the boilerplate alone. The 0.4.0 entry leads with what costs money if it goes unread: that pplns-coinbase cannot pay everyone in one block, that what it cannot pay goes to the other miners rather than the operator, and that being small costs a miner frequency rather than money. RELEASING.md says to write notes that way and shows how to preview exactly what will be published. ## The docs sweep this turned up Four gaps, all in the newest work: - proxy.conf.example described the payout floor as it was before the queue existed — the floor with no mention of where the money goes or that the skipped miner is paid first next time. That file is where an operator sets the number, so it is the worst place to be a version behind. - the refuse-to-publish behaviour was documented nowhere. A pool that cannot measure its window now publishes no job at all, which an operator will meet as "jobs stopped updating" with `pplns window walk did not cover` in the log. README and the HTML now say that is the guard firing, not a crash, and why holding the template back is the only safe direction here. - the HTML data model listed every table but the two this branch added. - README described the per-listener ceiling without ever showing the syntax. Diagrams still regenerate byte-identically, every anchor resolves, and the full C suite passes. --- .github/workflows/release.yaml | 19 ++++++ CHANGELOG.md | 111 +++++++++++++++++++++++++++++++++ README.md | 23 ++++++- RELEASING.md | 20 +++++- dashboard/README.md | 4 +- docs/simplepool.html | 23 +++++++ proxy.conf.example | 45 +++++++------ 7 files changed, 222 insertions(+), 23 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index bd2576b..f4c9bfa 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -147,7 +147,26 @@ jobs: run: | set -euo pipefail version="${GITHUB_REF_NAME#v}" + # This release's section of CHANGELOG.md, if it has one. Notes live + # in the repo and are reviewed in the PR that writes them, for the + # same reason the binary is: a release note pasted into the web UI + # traces back to nothing. { + if [ -f CHANGELOG.md ]; then + awk -v v="## ${version}" ' + index($0, v) == 1 { on = 1; print; next } + on && /^## / { exit } + on { print } + ' CHANGELOG.md > section.md + if [ -s section.md ]; then + cat section.md + echo + echo "---" + echo + else + echo "> No CHANGELOG.md section for ${version}." >&2 + fi + fi echo "## Install" echo echo '```sh' diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0beb6ee --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,111 @@ +# Changelog + +Notable changes per release. The newest version is first; each section is what +the release workflow publishes as that release's notes, above the install +boilerplate. + +Anything that changes what a miner is paid, or what an operator has to tell +their miners, is called out explicitly — those are the changes that cost +somebody money if they go unread. + +## 0.4.0 — three PPLNS modes, and coinbase-direct payouts + +The headline is that a pool no longer has to hold miners' money to run PPLNS. + +### Three new pool modes + +`pool_mode` gains `pplns-thunder`, `pplns-btc` and `pplns-coinbase`, alongside +the existing `solo` and `pps-classic`. All five are documented in +[README](README.md#the-five-modes), with a sequence diagram each in +[docs/simplepool.html](docs/simplepool.html). + +PPLNS divides a block among the shares that produced it, so **the pool never +owes more than it has just been paid**. There is no operator reserve to fund +and operator ruin is not a failure mode — the trade is that miners carry the +variance, which is why the fee is normally set lower than on PPS. + +- **`pplns-thunder`** settles over Thunder, reusing the existing payout worker. +- **`pplns-btc`** settles on Bitcoin L1 through the enforcer's own wallet. + Needs `bip300301_enforcer --enable-wallet` and `PAYOUT_RAIL=btc`. +- **`pplns-coinbase`** settles in the block itself. + +### `pplns-coinbase`: the pool never receives the reward + +The block's own coinbase pays the entire window, one output per miner. No pool +wallet, no payout worker, no ledger row, no maturity wait. A reorged block +simply never paid, so there is nothing to claw back. + +**What operators must tell their miners.** A coinbase has a fixed budget of +bytes, so one block cannot pay everyone in a large window. Two limits decide +who it pays — `coinbase_max_bytes` (default 1000) and +`pplns_payout_floor_sats` (default 546, the dust limit). + +A claim that clears neither is **shared out among the miners that block could +pay** — never the operator, who takes only its fee at every byte budget. The +skipped miner then goes **first in the queue** for the next block: a quarter of +every coinbase's payout slots are reserved for whoever has waited longest. + +So being a small miner here costs **frequency, not money**. That is the single +sentence to put on a pool page, and the proxy states the floor at startup, per +template, per block, and on the dashboard before a miner connects. + +The queue lives in `pplns_fractions`: a signed fraction of one block reward per +worker, summing to zero. **It is not a balance and the pool holds nothing +against it** — delete the table and nobody is owed a payment, the pool only +forgets whose turn it was. Rows are staged when a block is found and applied +only once it confirms, so an orphaned block rotates nobody. + +`coinbase_max_bytes` is settable **per listener**, and usually should be: the +ceiling is a marketplace rule that binds only on the port rented hashrate +connects to, and every byte of it costs a payout. + +``` +coinbase_max_bytes = 3000 +listener = port=3335 label=rental min_diff=500000 initial_diff=500000 max_coinbase_bytes=900 +``` + +### Safety + +- **The window walk is bounded.** Reading the PPLNS window used to re-scan the + entire `shares` table on every template — 250 ms per million rows, on the + template thread. It now walks back in bounded batches: flat in history size + rather than linear (8 M rows: 1033 ms → 1.08 ms). +- **A walk that cannot prove it covered the window returns an error**, and the + pool publishes no job rather than a wrong one. Miners keep working the last + job until it recovers. In this mode a wrong window is mined into a coinbase + and published, so there is no later pass that could notice. +- **Store writes use savepoints.** The store shares one SQLite connection + across three threads, and `BEGIN IMMEDIATE` failed outright when another was + mid-transaction — dropping the write with only a warning. This affected + `store_pplns_distribute` as well, which had been surviving on being retried + each tip. + +### Dashboard + +- Every mode gets its own guidance on the "About the numbers" card. Previously + all three PPLNS modes fell through to *"this pool has not published its mode + yet"*, directly beneath a header that named the mode correctly. +- Three places answered "not `pps-classic`" with the word *solo*: the worker + page's **Owed** field, the templates page's PPS rate, and the + `pps_difficulty` health check. +- **Pool solvency** counted `blocks_found.reward_sats` as pool revenue in + `pplns-coinbase`, where that is what the block paid the *miners* — reporting + a healthy margin for a pool that holds nothing. Now skipped, with the reason. + +### Testing + +One end-to-end regtest suite per mode, all in CI, each mining a real chain — +including `solo`, which had none anywhere despite being the default. See +[tests/README.md](tests/README.md). + +### Upgrading from 0.3.0 + +Nothing is required: `solo` and `pps-classic` are unchanged, and the new +`pool_meta` and `pplns_*` tables are created on open. To adopt a PPLNS mode, +set `pool_mode` and read that mode's section in +[INSTALL.md](INSTALL.md) — `pplns-coinbase` in particular refuses +`pool_btc_address`, because it has no pool wallet at all. + +## 0.3.0 and earlier + +See the [release list](https://github.com/LayerTwo-Labs/simplepool/releases). diff --git a/README.md b/README.md index e4710f9..4de8d7d 100644 --- a/README.md +++ b/README.md @@ -179,8 +179,14 @@ and what a stratum username is: marketplace rule enforced on the port the rented hashrate connects to, and every byte of it costs a payout — a 100-miner window pays 9 at 400 bytes and 93 at 3000 — so there is no reason to make your own miners live under a - limit their port is never measured against. Set it tight on the rental - listener and leave the rest alone. + limit their port is never measured against: + + ``` + coinbase_max_bytes = 3000 + listener = port=3335 label=rental min_diff=500000 initial_diff=500000 max_coinbase_bytes=900 + ``` + + A listener that sets none uses the server-wide value. - `pplns_payout_floor_sats` (default 546, the dust limit) is the minimum a claim must be worth to get an output at all. @@ -214,6 +220,16 @@ and what a stratum username is: only once it is confirmed, so an orphaned block — which paid nobody — rotates nobody. + **If the pool cannot measure the window, it publishes no job at all.** The + window is read back over a bounded walk of the shares table; if that walk + cannot prove it covered the configured window — an IO error, a lock held too + long — it returns an error rather than a short answer, and the template is + held back. Miners keep working the last job until it recovers, which costs + hashrate on a new tip but is the only safe direction: in this mode the window + is rendered into a coinbase and published, so a wrong one is mined, + irreversible, and invisible afterwards. `pplns window walk did not cover …` + in the log is that guard firing, not a crash. + The floor is disclosed in four places: the proxy states it at startup, logs how many miners in the current window fall below it, reports per block what was redistributed and to whom — and publishes the number to `pool_meta`, so @@ -691,7 +707,8 @@ mode, each mining a real chain: | `tests/test_payout_regtest.sh` | the Thunder payout rail settles and confirms | All of them run in CI. For the verification checklist behind each mode, see -[`VERIFY.md`](VERIFY.md). +[`VERIFY.md`](VERIFY.md); for what changed in each release, see +[`CHANGELOG.md`](CHANGELOG.md). ## Layout diff --git a/RELEASING.md b/RELEASING.md index b63ee6e..8e0a0db 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -8,7 +8,7 @@ to a commit anyone can check out.** Nothing is uploaded by hand. PR (bump VERSION) → merge → git tag vX.Y.Z → CI builds + publishes ``` -## 1. Bump the version in a PR +## 1. Write the notes, and bump the version, in a PR `VERSION` lives in the [Makefile](Makefile) and is compiled into the binary — `simplepool --version` reports it, and so does `/api/versions` on the @@ -17,13 +17,31 @@ because a release whose own binary reports a different version is worse than no release: it makes every later "which version is this box running?" answer untrustworthy. +Add a `## X.Y.Z` section at the top of [CHANGELOG.md](CHANGELOG.md) in the +same PR. The release job publishes that section verbatim above the install +boilerplate, so the notes are reviewed like everything else — a release note +pasted into the web UI traces back to nothing, which is the one thing this +process exists to prevent. A tag with no matching section still releases; it +just ships the boilerplate alone. + +Lead with anything that changes **what a miner is paid** or **what an operator +has to tell their miners**. Those are the lines that cost somebody money if +they go unread. + ```sh git checkout -b release-0.2.0 sed -i 's/^VERSION := .*/VERSION := 0.2.0/' Makefile +$EDITOR CHANGELOG.md # add the 0.2.0 section git commit -am "Release 0.2.0" gh pr create --fill ``` +Preview exactly what the release job will publish: + +```sh +awk -v v="## 0.2.0" 'index($0,v)==1{on=1;print;next} on&&/^## /{exit} on{print}' CHANGELOG.md +``` + Merge it. Everything below runs against `main`. ## 2. Tag diff --git a/dashboard/README.md b/dashboard/README.md index bf9993d..2ac571a 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -127,7 +127,9 @@ do the one thing that cannot work. That mode does not pay a claim worth less than `pplns_payout_floor_sats` — it shares it out among the miners that block could pay — never the operator, who takes only its fee — and puts the skipped miner first in the queue for the next -block. The card states the number before anyone connects, because the +block. The card says all three things, because a miner deciding whether to +point a rig here needs to know that being small costs them frequency rather +than money, and that nothing is being held on their behalf. The card states the number before anyone connects, because the operator's log is the one place the miner it costs cannot look. It renders only when the proxy published a floor (`pool_meta.pplns_payout_floor_sats`); an older proxy stores NULL, and printing a default there would be stating some diff --git a/docs/simplepool.html b/docs/simplepool.html index f234052..27579f3 100644 --- a/docs/simplepool.html +++ b/docs/simplepool.html @@ -515,6 +515,18 @@

    pool_mode = pplns-coinbase

    dashboard before a miner connects, because the operator's log is the one place the miner it affects cannot look.

    +

    + If the pool cannot measure the window, it publishes no job at + all. The window is read over a bounded walk of the shares table; + when that walk cannot prove it covered the configured window — an IO + error, a lock held past its timeout — it errors rather than returning a + short answer, and the template is held back. Miners keep working the last + job until it recovers. That costs hashrate on a new tip and is still the + only safe direction: here the window is rendered into a coinbase and + published, so a wrong one is mined, irreversible, and invisible + afterwards. pplns window walk did not cover … in the log is + that guard firing, not a crash. +

    @@ -2030,6 +2042,17 @@

    The data model

    settled payouts, and every transaction attempt with its stage and raw bytes for forensics depositsdashboard one row per operator-triggered BTC → Thunder deposit, with Ctip sequence before and after + pplns_fractionsproxy + pplns-coinbase only. A signed fraction of ONE block reward per worker: + positive means skipped by a block that had no room and first in the queue for the + next, negative means paid early out of somebody else's skipped share. Sums to zero. + Not a balance — nothing is held against it, and deleting the table + costs nobody a payment, it only forgets whose turn it was + pplns_pending_fractionsproxy + pplns-coinbase only. What a FOUND block did to those fractions, held + against its hash until the confirmation pass decides. A found block is a candidate: + applying it there would rotate a miner down the queue for a payment an orphan never + made
    diff --git a/proxy.conf.example b/proxy.conf.example index 9683b2c..2b463c6 100644 --- a/proxy.conf.example +++ b/proxy.conf.example @@ -244,24 +244,33 @@ pool_mode = solo # coinbase_max_bytes = 1000 # pplns-coinbase — the payout floor, in satoshis. A miner whose share of a -# block comes to less than this is NOT PAID. The money goes to the operator -# output, there is no ledger entry, and it is not settled later. -# -# This is a deliberate policy and it needs stating to your miners, because it -# is the one place this pool is harsher than a custodial one. A custodial -# pool can hold a tiny balance until it is worth a transaction. Coinbase-direct -# has nowhere to hold it: the payment IS the block, so the only alternatives -# are to pay an output that costs more in bytes than it is worth, or to carry -# a debt against a block that may never come. We do neither. -# -# The practical effect is a hashrate floor. With a 3.125 BTC block and a 2x -# window, a miner needs roughly a 546/312500000 share of the window — about -# 0.0002% — to clear the default. Anyone below that will mine here, produce -# valid shares, and receive nothing, which is worse for them than solo mining -# where they at least hold a lottery ticket. Say so on your pool page. -# -# Raising it makes blocks cheaper in bytes and the floor harsher. Clamped up -# to 546 (the dust limit) — no smaller output is relayable anyway. +# block is worth less than this gets no output in THAT block. +# +# What it was owed is not lost and does not go to you: it is shared out among +# the miners that block could pay, and the skipped miner goes first in the +# queue for the next one. You take your fee and nothing else, at every byte +# budget. +# +# That queue is `pplns_fractions` in the database — a signed fraction of one +# block reward per worker, positive if skipped and negative if paid early out +# of someone else's skipped share, summing to zero. A quarter of every +# coinbase's payout slots are reserved for whoever has waited longest, because +# a miner's share of the window tracks its hashrate: without that, the largest +# claims take the same slots in every block and the same addresses are never +# paid at all. +# +# It is NOT a balance and you hold nothing against it. Nothing is ever withheld +# from a coinbase and released later — that would need a block paying less than +# the reward followed by one paying more, and the second is invalid. Delete the +# table and nobody is owed a payment; the pool just forgets whose turn it was. +# Rows are staged when a block is found and applied only once it confirms, so +# an orphaned block rotates nobody. +# +# So being small here costs a miner FREQUENCY, not money. Say that on your +# pool page — the proxy states the floor at startup, per template, per block, +# and on the dashboard, but only you can put it where a miner looks first. +# +# Clamped up to 546 (the dust limit) — no smaller output is relayable anyway. # pplns_payout_floor_sats = 546 # pps-classic — OPTIONAL rate override, sats credited per unit of share From 9931cd74e2ec7d2286c2b8e57460260351c6bf10 Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 10 Sep 2026 11:43:43 +0200 Subject: [PATCH 28/36] Test the two things I had called untestable, and fix one of them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asked how to verify the two caveats I had been repeating, the honest answer turned out to be "measure them" — and one was wrong. ## expected_slots was over by 24 slots on taproot I had been describing it as a documented estimate. Measured against what the builder actually admits, across six budgets and three address types: P2WPKH within 1-2 slots P2PKH within 0-7 P2TR over by up to 24 at a 3000-byte budget Because it assumed 31 bytes an output and a P2TR output is 43. The consequence is bounded — everyone still receives their own claim — but it reserved a third of a coinbase for rotation where a quarter was meant. The caller has the addresses, so it now charges each one what it costs. Worst error across the same matrix is 2 slots, and always on the conservative side. A test pins both properties: never over, never more than 3 short. Reverting to a fixed 31 bytes fails it. ## "a persistent window failure freezes job updates" is testable in regtest I had said this needed production conditions. It does not: renaming `shares` from a second connection makes a live pool's window query fail with "no such table", which is a different cause from the IO errors the unit tests inject but the same path out of store_pplns_window() — and it exercises the real binary rather than a redirected sqlite3_step. The e2e now hides the table, forces a rebuild with a new tip, and asserts the property that matters: refused: window query failed: no such table: shares published no job while the window was unreadable (3, unchanged) resumed once the window was readable again (3 -> 5) The middle line is the one worth having. A pool that logged the failure and shipped a job anyway would be worse than one that crashed, because in this mode the window is rendered into a coinbase and published. Mutating attach_pplns_window to publish on failure fails the stage. What regtest genuinely cannot do is production SCALE — a 5.5 GB shares table, a hundred million rows. That is what Wired4ncer's run of the bounded walk against his own database covers, and nothing here replaces it. I had been using that limit to excuse two things it did not cover. --- CHANGELOG.md | 5 ++ src/coinbase.c | 35 ++++++++++++-- src/coinbase.h | 33 ++++++++----- src/main.c | 6 ++- tests/test_coinbase.c | 71 ++++++++++++++++++++++++++++ tests/test_pplns_coinbase_regtest.sh | 58 +++++++++++++++++++++++ 6 files changed, 193 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0beb6ee..a5da541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,11 @@ listener = port=3335 label=rental min_diff=500000 initial_diff=500000 max_coinba pool publishes no job rather than a wrong one. Miners keep working the last job until it recovers. In this mode a wrong window is mined into a coinbase and published, so there is no later pass that could notice. +- **The payout-slot estimate charges each address what it costs.** It decides + how many slots are reserved for long-waiting miners; assuming a fixed 31 + bytes was over by 24 slots on a window of taproot addresses at a 3000-byte + budget, reserving a third of the coinbase where a quarter was meant. Now + within 2 slots across every budget and address type tested, and never over. - **Store writes use savepoints.** The store shares one SQLite connection across three threads, and `BEGIN IMMEDIATE` failed outright when another was mid-transaction — dropping the write with only a warning. This affected diff --git a/src/coinbase.c b/src/coinbase.c index f94ba3d..fe6b2d1 100644 --- a/src/coinbase.c +++ b/src/coinbase.c @@ -1456,7 +1456,9 @@ static int cb_reward_probe(void *ctx, int64_t reward_sats, size_t fixed_bytes, char *errbuf, size_t errlen); size_t coinbase_expected_payout_slots(size_t max_coinbase_bytes, - const char *coinbase_tx_hex) + const char *coinbase_tx_hex, + const char *const *addresses, + size_t n_addresses) { size_t budget = max_coinbase_bytes ? max_coinbase_bytes : (size_t)COINBASE_DEFAULT_MAX_BYTES; @@ -1477,8 +1479,35 @@ size_t coinbase_expected_payout_slots(size_t max_coinbase_bytes, if (fixed > 31) fixed -= 31; } if (budget <= fixed) return 1; - /* 31 bytes is a P2WPKH payout, the common case. */ - size_t slots = (budget - fixed) / 31; + size_t room = budget - fixed; + + /* Charge each address what it actually costs, in the order the caller + * means to pay them. Assuming a fixed 31 bytes was wrong by 24 slots on a + * window of taproot addresses. */ + if (addresses && n_addresses > 0) { + size_t used = 0, slots = 0; + for (size_t i = 0; i < n_addresses; ++i) { + uint8_t spk[64]; + size_t spk_len = 0; + size_t cost; + if (!addresses[i] || + coinbase_address_to_script(addresses[i], spk, sizeof spk, + &spk_len, NULL, 0) < 0) { + cost = out_ser_size(22); /* unreadable: assume P2WPKH */ + } else { + cost = out_ser_size(spk_len); + } + if (used + cost > room) break; + used += cost; + slots++; + } + if (slots < 1) slots = 1; + if (slots > COINBASE_MAX_PAYOUT_OUTPUTS) slots = COINBASE_MAX_PAYOUT_OUTPUTS; + return slots; + } + + /* No window in hand: assume the common case. */ + size_t slots = room / 31; if (slots < 1) slots = 1; if (slots > COINBASE_MAX_PAYOUT_OUTPUTS) slots = COINBASE_MAX_PAYOUT_OUTPUTS; return slots; diff --git a/src/coinbase.h b/src/coinbase.h index 9f9da2e..979b3d1 100644 --- a/src/coinbase.h +++ b/src/coinbase.h @@ -199,20 +199,31 @@ void coinbase_parts_free(coinbase_parts_t *p); * enforcer (plus the mandatory BIP300/301 commitments), which is what tells an * observer whether a sidechain can be merge-mined into these blocks. * Returns 0 ok, negative on malformed input. */ -/* Roughly how many payouts a coinbase of `max_coinbase_bytes` will hold. +/* How many payouts a coinbase of `max_coinbase_bytes` will hold. * - * An estimate, and only used to decide how many payout slots to reserve for - * long-waiting miners — the real limit is applied by the builder, against the - * actual address types and the actual template. Getting this wrong changes the - * fairness of the rotation and never the arithmetic: everyone still receives - * their own claim, and whatever the budget cuts is still redistributed. + * Used to decide how many payout slots to reserve for long-waiting miners, so + * it wants to match what the builder will actually admit. It charges each + * address at its real serialized size rather than assuming one: a P2TR output + * is 43 bytes against a P2WPKH one's 31, and assuming 31 for a window of + * taproot addresses overestimated by 24 slots at a 3000-byte budget — enough + * to reserve a third of the coinbase for rotation where a quarter was meant. * - * `coinbase_tx_hex` may be NULL, for a coinbase built from scratch; when it is - * given, its existing outputs are charged against the budget the way the - * builder charges them, because on a drivechain the commitment OP_RETURNs are - * what actually decide how many miners fit. */ + * `addresses` may be NULL, in which case it falls back to assuming P2WPKH; + * that is only for callers with no window in hand. + * + * `coinbase_tx_hex` may be NULL, for a coinbase built from scratch; when given, + * its existing outputs are charged against the budget the way the builder + * charges them, because on a drivechain the commitment OP_RETURNs are what + * actually decide how many miners fit. + * + * Still only an estimate of the builder's answer, and deliberately so: getting + * it wrong changes the fairness of the rotation and never the arithmetic. + * Everyone in the order still receives their own claim, and whatever the + * budget cuts is still redistributed. */ size_t coinbase_expected_payout_slots(size_t max_coinbase_bytes, - const char *coinbase_tx_hex); + const char *coinbase_tx_hex, + const char *const *addresses, + size_t n_addresses); /* The reward a server-provided coinbasetxn actually pays, in sats: the value * of its single spendable output, which is the one the window replaces. diff --git a/src/main.c b/src/main.c index 35bc9cb..099f572 100644 --- a/src/main.c +++ b/src/main.c @@ -328,8 +328,12 @@ static int attach_pplns_window(store_t *store, const proxy_config_t *cfg, * accumulate. A fraction of the slots is therefore reserved for whoever * has waited longest. Costs no bytes, changes nobody's total, changes only * how often people are paid. */ + /* The real addresses, in claim order, so the estimate charges each output + * what it costs instead of assuming P2WPKH. */ + const char *addrs[COINBASE_MAX_PAYOUT_OUTPUTS]; + for (size_t i = 0; i < n; ++i) addrs[i] = claims[i].payout_address; size_t expected_slots = coinbase_expected_payout_slots( - (size_t)cfg->coinbase_max_bytes, t->coinbasetxn_hex); + (size_t)cfg->coinbase_max_bytes, t->coinbasetxn_hex, addrs, n); size_t order[COINBASE_MAX_PAYOUT_OUTPUTS]; if (pplns_order_claims(claims, n, expected_slots, order) < 0) { LOG_WARN("pplns-coinbase: could not order the window for payment"); diff --git a/tests/test_coinbase.c b/tests/test_coinbase.c index a331e65..8c9154b 100644 --- a/tests/test_coinbase.c +++ b/tests/test_coinbase.c @@ -1301,6 +1301,76 @@ static void test_the_template_reward_matches_what_the_builder_splits(void) { printf("ok: the template reward is exactly what the builder splits\n"); } +/* The slot estimate has to match what the builder actually admits. + * + * It decides how many payout slots are reserved for long-waiting miners, so + * being wrong shifts the rotation: too high reserves a share of a coinbase + * that does not exist, too low starves the queue. Neither breaks the + * arithmetic -- everyone still receives their own claim -- which is exactly + * why a drift here would go unnoticed without this. + * + * The first version assumed 31 bytes an output and was over by 24 slots on a + * window of taproot addresses at a 3000-byte budget: a third of the coinbase + * reserved where a quarter was meant. It now charges each address what it + * costs, and this pins the result to within a slot or two, ALWAYS on the + * conservative side. */ +static void test_the_slot_estimate_tracks_what_the_builder_admits(void) { + static const struct { const char *addr; const char *name; } KINDS[] = { + { WA, "P2WPKH" }, + { "bc1p5d7rjq7g6rdk2yhzks9smlaqtedr4dekq08ge8ztwac72sfr9rusxg3297", "P2TR" }, + { "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2", "P2PKH" }, + }; + static const size_t BUDGETS[] = { 300, 400, 600, 1000, 2000, 3000 }; + const int64_t payable = 4950000000LL; + + for (size_t b = 0; b < sizeof BUDGETS / sizeof BUDGETS[0]; ++b) { + for (size_t k = 0; k < sizeof KINDS / sizeof KINDS[0]; ++k) { + const char *addrs[150]; + for (int i = 0; i < 150; ++i) addrs[i] = KINDS[k].addr; + size_t est = coinbase_expected_payout_slots(BUDGETS[b], NULL, + addrs, 150); + /* The truth: grow the window until the builder starts cutting. */ + size_t actual_slots = 0; + for (int n = 1; n <= 150; ++n) { + coinbase_payee_t p[150]; + char err[256]; + int64_t each = payable / n, tot = 0; + for (int i = 0; i < n; ++i) { + p[i].address = KINDS[k].addr; p[i].sats = each; tot += each; + } + p[0].sats += payable - tot; + coinbase_parts_t parts; + coinbase_window_result_t r; + if (coinbase_build_window(800000, 5000000000LL, p, (size_t)n, + WOP, 100, NULL, "/sp/", 4, 8, + BUDGETS[b], 546, &parts, &r, + err, sizeof err) != 0) break; + coinbase_parts_free(&parts); + if (r.dropped_capped > 0) { actual_slots = r.paid_count; break; } + actual_slots = (size_t)n; + } + if (actual_slots == 0) continue; + long diff = (long)est - (long)actual_slots; + /* Never over: reserving slots a coinbase does not have would hand + * the rotation more of the block than the policy says. */ + if (diff > 0) { + printf("FAIL: %s at %zu bytes — estimate %zu exceeds the %zu " + "the builder admits\n", + KINDS[k].name, BUDGETS[b], est, actual_slots); + assert(0); + } + /* And close enough that the reservation still means something. */ + if (diff < -3) { + printf("FAIL: %s at %zu bytes — estimate %zu is %ld short of " + "the %zu admitted\n", + KINDS[k].name, BUDGETS[b], est, -diff, actual_slots); + assert(0); + } + } + } + printf("ok: the slot estimate tracks the builder within 3, never over\n"); +} + /* The two builders must divide a window identically. They share a resolver * precisely so that a drivechain pool and a plain-bitcoind pool cannot pay * the same miners different amounts. */ @@ -1439,6 +1509,7 @@ int main(void) { test_p2pkh_address(); test_the_built_coinbase_respects_its_budget(); test_commitments_eat_the_payout_budget(); + test_the_slot_estimate_tracks_what_the_builder_admits(); test_the_template_reward_matches_what_the_builder_splits(); test_both_window_builders_split_identically(); test_window_from_template_preserves_commitments(); diff --git a/tests/test_pplns_coinbase_regtest.sh b/tests/test_pplns_coinbase_regtest.sh index c545e80..f93432f 100755 --- a/tests/test_pplns_coinbase_regtest.sh +++ b/tests/test_pplns_coinbase_regtest.sh @@ -647,6 +647,64 @@ BAL2="$(sqlite3 "$MIX_DB" "SELECT CAST(ROUND(( exit 1; } echo " the applied queue still sums to zero" +stage "assert a pool that cannot measure its window publishes NO job" +# The safety property the bounded walk exists for. If the window cannot be +# measured, the proxy must hold the template back rather than mine a window it +# is not sure of: here the window is rendered into a coinbase and published, so +# a wrong one is irreversible and nothing downstream can notice it. +# +# Reachable in regtest after all. Renaming `shares` from a second connection +# makes the live pool's window query fail with "no such table" — a different +# cause from the IO errors and lock timeouts the unit tests inject, but the +# same path out of store_pplns_window(), and it exercises the real binary +# rather than a redirected sqlite3_step. +JOBS_BEFORE="$(grep -c 'new job: height=' "$MIX_LOG")" +sqlite3 "$MIX_DB" "ALTER TABLE shares RENAME TO shares_hidden;" +echo " shares table hidden; forcing a rebuild with a new tip" + +# A new tip is when it matters most, and it forces a rebuild immediately +# rather than waiting out the 30s refresh. +mine_one() { + RPC_TIMEOUT=60 "$ROOT/scripts/enforcer-rpc.sh" \ + cusf.mainchain.v1.MiningService/GenerateToAddress \ + '{"blocks": 1, "address": "'"$OPERATOR_ADDR"'"}' >/dev/null 2>&1 || true +} +mine_one +for _ in $(seq 1 20); do + grep -qE 'window query failed|did not cover' "$MIX_LOG" && break + sleep 1 +done +grep -qE 'window query failed|did not cover' "$MIX_LOG" || { + echo "FAIL: the window became unreadable and the pool never said so" >&2 + tail -20 "$MIX_LOG" >&2 + sqlite3 "$MIX_DB" "ALTER TABLE shares_hidden RENAME TO shares;" 2>/dev/null + exit 1; } +echo " refused: $(grep -oE '(window query failed|pplns window walk did not cover)[^\"]{0,40}' "$MIX_LOG" | tail -1)" + +# And it published nothing on that tip. This is the assertion that matters: +# holding the template back is the whole point, and a pool that logged the +# failure but shipped a job anyway would be worse than one that crashed. +JOBS_BROKEN="$(grep -c 'new job: height=' "$MIX_LOG")" +[ "$JOBS_BROKEN" = "$JOBS_BEFORE" ] || { + echo "FAIL: the pool could not measure its window and published a job anyway" >&2 + echo " ($JOBS_BEFORE jobs before, $JOBS_BROKEN after)" >&2 + sqlite3 "$MIX_DB" "ALTER TABLE shares_hidden RENAME TO shares;" 2>/dev/null + exit 1; } +echo " published no job while the window was unreadable ($JOBS_BEFORE, unchanged)" + +# Recovery: it is a hold, not a latch. +sqlite3 "$MIX_DB" "ALTER TABLE shares_hidden RENAME TO shares;" +mine_one +for _ in $(seq 1 40); do + JOBS_AFTER="$(grep -c 'new job: height=' "$MIX_LOG")" + [ "${JOBS_AFTER:-0}" -gt "$JOBS_BROKEN" ] && break + sleep 1 +done +[ "${JOBS_AFTER:-0}" -gt "$JOBS_BROKEN" ] || { + echo "FAIL: the window came back and the pool never resumed publishing" >&2 + tail -20 "$MIX_LOG" >&2; exit 1; } +echo " resumed once the window was readable again ($JOBS_BROKEN -> $JOBS_AFTER)" + echo echo "cbwin-e2e: PASS (the window was paid from the block's own coinbase," echo " and the pool never held the reward)" From 4b941000288e107a0e60522018b78e4bd303a1ac Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 10 Sep 2026 18:16:10 +0200 Subject: [PATCH 29/36] store: serialise transactions with a mutex, not savepoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wired4ncer caught this in review before 0.4.0 ships, and he is right: the savepoint fix could lose more than the bug it replaced, and more quietly. Both of his claims reproduce in plain sqlite, no pool involved: ROLLBACK TO discards intervening writes: 0 rows survive (RELEASE: 1) a RELEASEd nested write, outer rollback: 0 rows survive A savepoint joins whatever transaction is already open. On this connection that is usually the commit thread's share batch, so: - RELEASE does not commit. When commit_batch() hits a failed COMMIT it runs ROLLBACK and replays the batch — the shares come back, the nested write does not, and its caller was already told it succeeded. - ROLLBACK TO is not scoped to the caller that opened the savepoint. It rewinds the connection, taking the commit thread's shares with it. That is reachable from six ordinary error paths across the three functions. So the trade was a lost payout-queue row for lost SHARES, which is what every window is measured from. His premise checks out too, and it is the same one my own commit message stated: writer_main() releases `mu` before calling commit_batch(), and commit_batch() takes no lock at all, so `BEGIN IMMEDIATE … COMMIT` ran unserialised against everything else on that connection. Fixed as he suggested — one mutex held across the whole transaction span, in commit_batch() and around each of the three functions, keeping BEGIN IMMEDIATE. A stratum-path write now waits for at most one batch, bounded by commit_window_ms. Taken per attempt rather than around the retry loop, so a backoff does not hold every other writer off for the sleep. On his deadlock question: none of the three is reachable from the commit thread — process_event() calls none of them, and their only callers are reconcile.c and main.c — so a plain mutex is safe. Checked rather than assumed. Two tests. One drives the real race: shares streaming in while the queue is written, asserting nothing is refused and no row of either kind is lost. That one passes on savepoints, because the loss needs the outer batch to actually roll back — so the second plays the commit thread's failed COMMIT on another thread and asserts the staged rows survive it. That one fails on savepoints with "staging returned before the other transaction ended, so it nested". Verified: full C suite, ASan, all six regtest e2e suites. --- CHANGELOG.md | 11 +-- src/store.c | 119 +++++++++++++++++---------- src/store.h | 1 + tests/test_store.c | 196 +++++++++++++++++++++++++++++---------------- 4 files changed, 210 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5da541..394a70d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,11 +79,12 @@ listener = port=3335 label=rental min_diff=500000 initial_diff=500000 max_coinba bytes was over by 24 slots on a window of taproot addresses at a 3000-byte budget, reserving a third of the coinbase where a quarter was meant. Now within 2 slots across every budget and address type tested, and never over. -- **Store writes use savepoints.** The store shares one SQLite connection - across three threads, and `BEGIN IMMEDIATE` failed outright when another was - mid-transaction — dropping the write with only a warning. This affected - `store_pplns_distribute` as well, which had been surviving on being retried - each tip. +- **Store transactions are serialised.** The store shares one SQLite connection + across three threads and nothing guarded it: `BEGIN IMMEDIATE` failed + outright when another was mid-transaction, dropping the write with only a + warning. `store_pplns_distribute` was affected too, surviving on being + retried each tip. A single mutex is now held across each transaction, so a + write waits for at most one batch instead of losing to it. ### Dashboard diff --git a/src/store.c b/src/store.c index 65158ff..4f6f583 100644 --- a/src/store.c +++ b/src/store.c @@ -436,6 +436,30 @@ struct store { sqlite3_stmt *st_upsert_node_tip; sqlite3_stmt *st_upsert_credit; pthread_mutex_t node_tip_mu; /* serialise binds on st_upsert_node_tip */ + /* Held across a WHOLE transaction on `db`, by every thread that opens one. + * + * One connection is shared by the commit thread, the tip watcher and the + * stratum submit path, and none of the other locks covers this: `mu` + * guards the ring buffer and is released before commit_batch() runs, and + * node_tip_mu guards a single statement. So two transactions could + * overlap, and BEGIN IMMEDIATE simply failed for the loser -- a race, so + * it showed up as an occasional lost write rather than anything + * reproducible. + * + * Savepoints look like the fix and are worse. A savepoint nests into + * whatever is already open, which on this connection is usually the + * commit thread's share batch -- so RELEASE does not commit the write (a + * failed batch discards it after its caller was told it succeeded), and + * ROLLBACK TO rewinds the connection past the caller's own boundary, + * taking the commit thread's shares with it. Verified both in plain + * sqlite; see the tests. That trades a lost payout-queue row for lost + * SHARES, which is what every window is measured from. + * + * Serialising is what these writes actually need. A stratum-path write + * waits for at most one batch, bounded by commit_window_ms. Not recursive: + * nothing reachable from commit_batch() opens one of these transactions, + * which is checked by the tests rather than assumed. */ + pthread_mutex_t txn_mu; /* Ring buffer */ event_t *ring; @@ -693,7 +717,17 @@ static void process_event(store_t *s, const event_t *ev) { static int commit_batch(store_t *s, event_t *batch, size_t take) { for (int attempt = 1; attempt <= STORE_COMMIT_ATTEMPTS; ++attempt) { char *err = NULL; + /* Held for the whole transaction, so nothing else on this connection + * can open one inside it. writer_main() releases `mu` before calling + * here -- that lock guards the ring buffer, not the database -- so + * without this the tip watcher and the stratum submit path could and + * did overlap a batch. See txn_mu. + * + * Taken per attempt rather than around the retry loop, so a backoff + * does not hold every other writer off for the sleep. */ + pthread_mutex_lock(&s->txn_mu); if (sqlite3_exec(s->db, "BEGIN IMMEDIATE", NULL, NULL, &err) != SQLITE_OK) { + pthread_mutex_unlock(&s->txn_mu); LOG_WARN("store: BEGIN failed (attempt %d/%d): %s", attempt, STORE_COMMIT_ATTEMPTS, err ? err : "?"); sqlite3_free(err); @@ -705,6 +739,7 @@ static int commit_batch(store_t *s, event_t *batch, size_t take) { for (size_t i = 0; i < take; ++i) process_event(s, &batch[i]); if (sqlite3_exec(s->db, "COMMIT", NULL, NULL, &err) == SQLITE_OK) { + pthread_mutex_unlock(&s->txn_mu); atomic_fetch_add(&s->batches, 1); return 0; } @@ -715,6 +750,7 @@ static int commit_batch(store_t *s, event_t *batch, size_t take) { * per-event counters process_event() bumped are lost accuracy we * accept: they describe attempts, the ledger describes reality. */ sqlite3_exec(s->db, "ROLLBACK", NULL, NULL, NULL); + pthread_mutex_unlock(&s->txn_mu); atomic_fetch_add(&s->pg_errors, 1); backoff_sleep(attempt); } @@ -914,6 +950,7 @@ int store_open(const store_cfg_t *cfg, store_t **out) { " last_updated = excluded.last_updated"; pthread_mutex_init(&s->node_tip_mu, NULL); + pthread_mutex_init(&s->txn_mu, NULL); if (sqlite3_prepare_v2(s->db, Q_UPSERT, -1, &s->st_upsert_worker, NULL) != SQLITE_OK || sqlite3_prepare_v2(s->db, Q_INS_SHARE, -1, &s->st_insert_share, NULL) != SQLITE_OK || @@ -957,6 +994,7 @@ void store_close(store_t *s) { if (s->st_upsert_node_tip) sqlite3_finalize(s->st_upsert_node_tip); if (s->st_upsert_credit) sqlite3_finalize(s->st_upsert_credit); if (s->db) sqlite3_close(s->db); + pthread_mutex_destroy(&s->txn_mu); pthread_mutex_destroy(&s->node_tip_mu); pthread_mutex_destroy(&s->mu); pthread_cond_destroy(&s->cv_not_empty); @@ -1271,38 +1309,27 @@ int store_record_block(store_t *s, uint64_t ts_ms, int height, /* ---- PPLNS distribution ------------------------------------------------ */ -/* Transactions that may already be inside one. +/* One transaction, serialised against every other on this connection. * - * The store keeps ONE sqlite connection and shares it: the commit thread - * batches shares on it, while the tip watcher and the stratum submit path also - * write through it. BEGIN IMMEDIATE fails outright when the commit thread - * happens to be mid-batch -- "cannot start a transaction within a transaction" - * -- which is a race, so it shows up as an occasional lost write rather than - * as anything reproducible. - * - * SAVEPOINT nests. With no transaction open it starts one; inside another it - * is a nested unit that RELEASE folds into the outer commit. Either way the - * caller gets all-or-nothing, which is the property these writes actually - * need. - * - * Found when the coinbase-direct payout queue silently dropped a block's - * rotation under load (LayerTwo-Labs/simplepool#76). store_pplns_distribute() - * had the same bug and had been surviving on being retried each tip. */ -static int sp_begin(store_t *s, const char *name) { - char q[64]; - snprintf(q, sizeof q, "SAVEPOINT %s", name); - return sqlite3_exec(s->db, q, NULL, NULL, NULL); + * txn_begin() takes txn_mu and opens a real BEGIN IMMEDIATE; commit and + * rollback close it and release the lock. Pairing the lock with the + * transaction in one place is the point -- an unlock that can be forgotten on + * an error path is how this class of bug gets back in. See txn_mu. */ +static int txn_begin(store_t *s) { + pthread_mutex_lock(&s->txn_mu); + if (sqlite3_exec(s->db, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) { + pthread_mutex_unlock(&s->txn_mu); + return -1; + } + return 0; } -static void sp_release(store_t *s, const char *name) { - char q[64]; - snprintf(q, sizeof q, "RELEASE %s", name); - sqlite3_exec(s->db, q, NULL, NULL, NULL); +static void txn_commit(store_t *s) { + sqlite3_exec(s->db, "COMMIT", NULL, NULL, NULL); + pthread_mutex_unlock(&s->txn_mu); } -static void sp_rollback(store_t *s, const char *name) { - char q[96]; - snprintf(q, sizeof q, "ROLLBACK TO %s", name); - sqlite3_exec(s->db, q, NULL, NULL, NULL); - sp_release(s, name); +static void txn_rollback(store_t *s) { + sqlite3_exec(s->db, "ROLLBACK", NULL, NULL, NULL); + pthread_mutex_unlock(&s->txn_mu); } int store_pplns_distribute(store_t *s, int maturity_confs, int fee_bps, @@ -1401,7 +1428,7 @@ int store_pplns_distribute(store_t *s, int maturity_confs, int fee_bps, * 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 (sp_begin(s, "sp_dist") != SQLITE_OK) { + if (txn_begin(s) != 0) { rc_out = -1; break; } @@ -1445,7 +1472,7 @@ int store_pplns_distribute(store_t *s, int maturity_confs, int fee_bps, sqlite3_finalize(mark); if (ok) { - sp_release(s, "sp_dist"); + txn_commit(s); blocks++; workers += credited_here; LOG_INFO("pplns: block %.16s… distributed %lld sats of %lld across " @@ -1453,7 +1480,7 @@ int store_pplns_distribute(store_t *s, int maturity_confs, int fee_bps, hbuf, (long long)distributed, (long long)payable, credited_here, window); } else { - sp_rollback(s, "sp_dist"); + txn_rollback(s); if (errbuf && errlen) snprintf(errbuf, errlen, "distribute %.16s: %s", hbuf, sqlite3_errmsg(s->db)); @@ -1686,14 +1713,14 @@ int store_stage_block_fractions(store_t *s, const char *block_hash, "VALUES (?, ?, ?) " "ON CONFLICT(block_hash, worker_id) DO UPDATE SET delta = excluded.delta"; - if (sp_begin(s, "sp_stage") != SQLITE_OK) { + if (txn_begin(s) != 0) { if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); return -1; } sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(s->db, Q, -1, &st, NULL) != SQLITE_OK) { if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); - sp_rollback(s, "sp_stage"); + txn_rollback(s); atomic_fetch_add(&s->pg_errors, 1); return -2; } @@ -1710,11 +1737,11 @@ int store_stage_block_fractions(store_t *s, const char *block_hash, sqlite3_finalize(st); if (!ok) { if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); - sp_rollback(s, "sp_stage"); + txn_rollback(s); atomic_fetch_add(&s->pg_errors, 1); return -2; } - sp_release(s, "sp_stage"); + txn_commit(s); return wrote; } @@ -1732,7 +1759,7 @@ int store_settle_block_fractions(store_t *s, int *out_applied, /* One transaction for the whole settlement. A partially applied block * would leave the ledger not summing to zero, and unlike a failed payout * there is no later pass that could notice: the pending rows are gone. */ - if (sp_begin(s, "sp_settle") != SQLITE_OK) { + if (txn_begin(s) != 0) { if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); return -1; } @@ -1753,7 +1780,7 @@ int store_settle_block_fractions(store_t *s, int *out_applied, sqlite3_stmt *sel = NULL; if (sqlite3_prepare_v2(s->db, ONE_HASH, -1, &sel, NULL) != SQLITE_OK) { if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); - sp_rollback(s, "sp_settle"); + txn_rollback(s); return -2; } while (nh < 64 && sqlite3_step(sel) == SQLITE_ROW) { @@ -1798,11 +1825,11 @@ int store_settle_block_fractions(store_t *s, int *out_applied, if (!ok) { if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); - sp_rollback(s, "sp_settle"); + txn_rollback(s); atomic_fetch_add(&s->pg_errors, 1); return -2; } - sp_release(s, "sp_settle"); + txn_commit(s); if (out_applied) *out_applied = applied; if (out_discarded) *out_discarded = discarded; return 0; @@ -1810,13 +1837,19 @@ int store_settle_block_fractions(store_t *s, int *out_applied, int store_begin_txn_for_test(store_t *s) { if (!s || !s->db) return -1; - return sqlite3_exec(s->db, "BEGIN IMMEDIATE", NULL, NULL, NULL) == SQLITE_OK - ? 0 : -1; + return txn_begin(s); } int store_end_txn_for_test(store_t *s) { if (!s || !s->db) return -1; - return sqlite3_exec(s->db, "COMMIT", NULL, NULL, NULL) == SQLITE_OK ? 0 : -1; + txn_commit(s); + return 0; +} + +int store_rollback_txn_for_test(store_t *s) { + if (!s || !s->db) return -1; + txn_rollback(s); + return 0; } int store_record_credit(store_t *s, const char *worker_name, diff --git a/src/store.h b/src/store.h index fa7bb95..47ad68e 100644 --- a/src/store.h +++ b/src/store.h @@ -268,6 +268,7 @@ int store_settle_block_fractions(store_t *s, int *out_applied, * racing a real one. Not for production use. */ int store_begin_txn_for_test(store_t *s); int store_end_txn_for_test(store_t *s); +int store_rollback_txn_for_test(store_t *s); int store_record_credit(store_t *s, const char *worker_name, const char *payout_address, diff --git a/tests/test_store.c b/tests/test_store.c index c0416c2..7acc28a 100644 --- a/tests/test_store.c +++ b/tests/test_store.c @@ -1718,97 +1718,154 @@ static void test_the_window_reports_each_workers_standing(void) { printf(" ok test_the_window_reports_each_workers_standing\n"); } -/* Writes that land while the commit thread holds a transaction. +/* Writes from three threads on one connection must not overlap, and none may + * be lost. * - * The store keeps ONE sqlite connection and shares it between the commit - * thread, the tip watcher and the stratum submit path. BEGIN IMMEDIATE fails - * outright when another thread is already mid-transaction — "cannot start a - * transaction within a transaction" — and because it is a race it shows up as - * an occasional lost write rather than as anything reproducible. + * The store shares a single sqlite connection between the commit thread, the + * tip watcher and the stratum submit path, and none of the other locks covers + * that: `mu` guards the ring buffer and writer_main() releases it BEFORE + * calling commit_batch(), which takes no lock at all. Two transactions could + * therefore overlap, and BEGIN IMMEDIATE simply failed for the loser. Being a + * race, it surfaced as an occasional dropped write with a WARN rather than + * anything reproducible — a block's payout-queue rotation, in the case that + * caught it. * - * It cost a real one: a block's payout-queue rotation was dropped with only a - * WARN, so the miner it skipped never moved up the queue. Caught by the - * regtest e2e, which had passed on the same code minutes earlier. + * Savepoints were the first fix and were worse: RELEASE does not commit a + * nested write (a failed batch discards it after its caller returned success) + * and ROLLBACK TO rewinds past the caller's own boundary, taking the commit + * thread's shares with it. So a lost queue row became lost SHARES, silently. + * Caught in review by Wired4ncer, on #76. * - * Simulated deterministically here by opening a transaction on the store's own - * connection first, which is exactly the state the commit thread leaves it in. - * SAVEPOINT nests where BEGIN cannot. */ -static void test_writes_nest_inside_an_open_transaction(void) { + * This drives the actual race: shares stream in — so the commit thread is + * opening and closing real batches throughout — while the queue is written + * from this thread. Every call must succeed, and every row must be there at + * the end. */ +static void test_concurrent_writers_do_not_lose_each_other(void) { const char *path = fresh_db_path(); store_cfg_t cfg = {0}; snprintf(cfg.path, sizeof(cfg.path), "%s", path); + /* Small window and batch, so the commit thread is busy rather than idle. */ + cfg.commit_window_ms = 1; + cfg.commit_max_shares = 4; store_t *s = NULL; assert(store_open(&cfg, &s) == 0); char err[256] = {0}; + enum { ROUNDS = 120 }; + int staged_ok = 0; + for (int i = 0; i < ROUNDS; ++i) { + /* Keep the writer thread in and out of transactions underneath us. */ + for (int k = 0; k < 8; ++k) { + char nm[32]; + snprintf(nm, sizeof nm, "w%d", k % 4); + assert(store_record_share_addr(s, nm, "addr_x", + 1000ULL + (uint64_t)(i * 8 + k), + 1.0, 0, NULL, 0, 0.0) == 0); + } + char hash[32]; + snprintf(hash, sizeof hash, "blk_%d", i); + store_fraction_delta_t d[] = { {1, 0.25}, {2, -0.25} }; + int rc = store_stage_block_fractions(s, hash, d, 2, err, sizeof err); + if (rc < 0) { + printf("FAIL: staging lost to the commit thread on round %d: %s\n", + i, err); + assert(0 && "a write must not be refused because a batch was open"); + } + staged_ok++; + } + assert(store_flush(s) == 0); + sqlite3 *db = NULL; assert(sqlite3_open(path, &db) == SQLITE_OK); - sqlite3_exec(db, "INSERT INTO workers (id,name,payout_address,first_seen,last_seen)" - " VALUES (1,'a','bc1qa',1,1),(2,'b','bc1qb',1,1)", NULL, NULL, NULL); - sqlite3_exec(db, "INSERT INTO blocks_found (ts,height,hash,reward_sats,fee_sats,status)" - " VALUES (1,10,'h1',100,1,'confirmed')", NULL, NULL, NULL); + /* Every staged round is present: nothing was silently discarded by a + * batch that rolled back underneath it. */ + int64_t staged_rows = scalar_i64(db, "SELECT COUNT(*) FROM pplns_pending_fractions"); + if (staged_rows != (int64_t)ROUNDS * 2) { + printf("FAIL: %d rounds staged 2 rows each, %lld survive\n", + staged_ok, (long long)staged_rows); + assert(0 && "staged rows were lost"); + } + /* And the shares the commit thread was writing all the while are intact — + * this is the half a ROLLBACK TO would have eaten. */ + int64_t share_rows = scalar_i64(db, "SELECT COUNT(*) FROM shares"); + if (share_rows != (int64_t)ROUNDS * 8) { + printf("FAIL: %d shares recorded, %lld survive\n", + ROUNDS * 8, (long long)share_rows); + assert(0 && "shares were lost"); + } sqlite3_close(db); + store_close(s); + printf(" ok test_concurrent_writers_do_not_lose_each_other " + "(%d rounds, %lld shares, %lld staged rows)\n", + staged_ok, (long long)share_rows, (long long)staged_rows); +} + +/* A write must not be discarded by somebody else's rollback. + * + * This is the failure the savepoint version had, and the reason the fix is a + * mutex rather than nesting. A savepoint joins whatever transaction is already + * open — on this connection, usually the commit thread's share batch — and + * RELEASE does not commit it. So when commit_batch() hits a failed COMMIT and + * runs ROLLBACK to replay the batch, the nested write goes with it, after its + * caller was already told it succeeded. The shares are replayed; nothing + * replays the nested row. + * + * Played out here with the commit thread's half on a second thread: it opens a + * transaction, and rolls it back exactly as commit_batch() does on a failed + * COMMIT. The staged rows must survive, which they only do if staging waited + * for that transaction instead of joining it. */ +typedef struct { store_t *s; int started; int done; } rollback_ctx_t; + +static void *rollback_thread(void *arg) { + rollback_ctx_t *c = (rollback_ctx_t *)arg; + assert(store_begin_txn_for_test(c->s) == 0); + __atomic_store_n(&c->started, 1, __ATOMIC_SEQ_CST); + /* Hold it long enough that a staging call made now would have to make a + * choice: wait, or nest into this. */ + struct timespec ts = { 0, 150 * 1000 * 1000 }; + nanosleep(&ts, NULL); + assert(store_rollback_txn_for_test(c->s) == 0); + __atomic_store_n(&c->done, 1, __ATOMIC_SEQ_CST); + return NULL; +} + +static void test_a_write_survives_another_threads_rollback(void) { + const char *path = fresh_db_path(); + store_cfg_t cfg = {0}; + snprintf(cfg.path, sizeof(cfg.path), "%s", path); + store_t *s = NULL; + assert(store_open(&cfg, &s) == 0); + char err[256] = {0}; - /* Put the shared connection in the state the commit thread leaves it in. */ - assert(store_begin_txn_for_test(s) == 0); + rollback_ctx_t ctx = { s, 0, 0 }; + pthread_t th; + assert(pthread_create(&th, NULL, rollback_thread, &ctx) == 0); + while (!__atomic_load_n(&ctx.started, __ATOMIC_SEQ_CST)) { } + /* The transaction that is about to be rolled back is open right now. */ store_fraction_delta_t d[] = { {1, 0.25}, {2, -0.25} }; - int rc = store_stage_block_fractions(s, "h1", d, 2, err, sizeof err); - if (rc < 0) { - printf("FAIL: staging inside an open transaction failed: %s\n", err); - assert(0 && "a nested write must not be refused"); - } + int rc = store_stage_block_fractions(s, "blk_rb", d, 2, err, sizeof err); assert(rc == 2); + /* If staging joined that transaction rather than waiting for it, this + * returned success and the rollback below eats the rows. */ + assert(__atomic_load_n(&ctx.done, __ATOMIC_SEQ_CST) == 1 && + "staging returned before the other transaction ended, so it nested"); - int applied = 0, discarded = 0; - assert(store_settle_block_fractions(s, &applied, &discarded, - err, sizeof err) == 0); - assert(applied == 1); - - assert(store_end_txn_for_test(s) == 0); - - /* store_pplns_distribute() has the same shape and had the same bug — it - * was surviving on being retried every tip, which is why it never looked - * like one. Its savepoint only runs when there is a matured block to - * distribute, so give it one and drive it nested too. */ - assert(store_record_share_addr(s, "alice", "addr_a", 2000, 50.0, - 0, NULL, 0, 0.0) == 0); - assert(store_record_share_addr(s, "bob", "addr_b", 2001, 50.0, - 0, NULL, 0, 0.0) == 0); - /* The block-finding share the distributor anchors its window on, at - * difficulty 0 so it does not shift the split. */ - assert(store_record_share_addr(s, "alice", "addr_a", 2002, 0.0, - 1, "blk_nest", 0, 0.0) == 0); - assert(store_record_block(s, 3000, 800100, "blk_nest", "alice", "addr_a", - 90000, 10000, STORE_BLOCK_PENDING, NULL, - 100.0) == 0); + pthread_join(th, NULL); assert(store_flush(s) == 0); - assert(store_set_block_status(s, "blk_nest", STORE_BLOCK_CONFIRMED, - 100, "node") == 0); - assert(store_begin_txn_for_test(s) == 0); - int nblocks = 0, nworkers = 0; - char derr[256] = {0}; - int drc = store_pplns_distribute(s, 100, 0, &nblocks, &nworkers, - derr, sizeof derr); - if (drc < 0) { - printf("FAIL: distribute inside an open transaction failed: %s\n", derr); - assert(0 && "a nested distribute must not be refused"); - } - assert(nblocks == 1); - assert(nworkers == 2); - assert(store_end_txn_for_test(s) == 0); - - /* And it really committed, rather than being rolled back with the outer. */ + sqlite3 *db = NULL; assert(sqlite3_open(path, &db) == SQLITE_OK); - char buf[64]; - scalar_text(db, "SELECT CAST(ROUND(owed_fraction*100) AS INT) " - "FROM pplns_fractions WHERE worker_id=1", buf, sizeof buf); - assert(strcmp(buf, "25") == 0); + int64_t rows = scalar_i64(db, "SELECT COUNT(*) FROM pplns_pending_fractions"); + if (rows != 2) { + printf("FAIL: staging reported success and %lld of 2 rows survive — " + "the write was discarded by another thread's rollback\n", + (long long)rows); + assert(0); + } sqlite3_close(db); - store_close(s); - printf(" ok test_writes_nest_inside_an_open_transaction\n"); + printf(" ok test_a_write_survives_another_threads_rollback\n"); } /* The operator fee comes off the top, exactly as in solo and PPS. */ @@ -1869,7 +1926,8 @@ int main(void) { test_fraction_deltas_must_sum_to_zero(); test_only_a_confirmed_block_moves_the_queue(); test_two_confirmed_blocks_both_count(); - test_writes_nest_inside_an_open_transaction(); + test_concurrent_writers_do_not_lose_each_other(); + test_a_write_survives_another_threads_rollback(); test_the_window_reports_each_workers_standing(); test_pplns_distributes_two_blocks_in_one_pass(); test_an_empty_window_returns_nothing_not_an_error(); From e9cbe96a8ca72dd5a5aa2e5712f41e2b37bfbaa3 Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 10 Sep 2026 19:15:20 +0200 Subject: [PATCH 30/36] pplns-coinbase: the payout queue was crediting the wrong miners Review of the whole rail, with the bugs it turned up fixed and pinned. The one that mattered: stratum.c reconstructed the set of miners a block had paid as "the first res.paid_count payees". The builder does not stop at the first payee it cannot pay -- it skips it and keeps going, because a later one may be a cheaper address type and still fit -- so the paid set is a SUBSEQUENCE of the window, never a prefix of it. When the dropped payee was not last, the ledger inverted: the miner the block SKIPPED was recorded as paid early and sent to the back of the queue, and a miner that WAS paid was recorded as owed and promoted ahead of it. Exactly backwards, on the commonest case there is -- pplns_order_claims() deliberately puts a reserved small claim first, and a small claim is what the floor drops. The regtest could not see it. A fresh window is ordered largest-first, so the dropped claim is always the tail and the prefix reading is right by coincidence; it only bites once the queue has started rotating. The builder now reports paid_payee[] and stratum.c reads that. Also: - store_stage_block_fractions() checked the deltas summed to zero and THEN skipped rows with no worker behind them, so a set that balanced only with the orphan reached the table unbalanced -- by passing the check rather than failing it. Summed over what is actually written now. - A floor nobody clears made the pool publish no work at all: the builder refuses such a window, per connection, per job, with a warning per miner per template and no single cause to find. Reachable without anything exotic -- 5000 sats across 20 miners is 250 each. Caught at template time now, once, with the arithmetic and the two knobs named. - The slot reservation was sized from the server-wide byte ceiling while the ceiling that binds is per-listener. Oversizing it is not symmetric with undersizing: reserve more positions than a tight coinbase has slots and every slot goes to the queue, so the largest claims are paid nothing and immediately re-enter the queue themselves. Sized from the tightest ceiling any listener can impose. - A reserved slot could go to a claim the floor was about to drop, which pays nobody and denies a miner that could have used it. It compounds: a permanently sub-floor miner's owed_fraction only grows, while a byte-capped one is paid and resets, so given long enough the miners who can never be paid crowd out the ones the rotation exists for. main.c now splits before ordering -- which also restores pplns_split_window's documented largest-first precondition, so the truncation remainder lands on the largest claim again -- and pplns_order_claims() skips what it cannot pay. - conn_render_coinbase() reached the solo shape by a goto into an `else if (0)` block sitting beside a verbatim copy of itself. One renderer now. - store_settle_block_fractions() opened a BEGIN IMMEDIATE on every reconcile pass in every mode, taking the write lock and txn_mu to settle a table that is empty and always will be on four of the five. Gated on a read. - config.h, coinbase.h, pplns.h and both operator-facing log lines still said a dropped claim is "forfeited to the operator, not carried". It goes to the other miners and is recorded as a turn. The dashboard already said so, so the operator's own logs contradicted the page shown to their miners. Every fix has a test that fails without it. The two that could not be reached from a chain -- the non-tail drop, and settling with the write lock held -- are pinned in test_stratum.c and test_store.c. --- src/coinbase.c | 6 + src/coinbase.h | 27 +++- src/config.h | 20 ++- src/main.c | 150 +++++++++++++---- src/pplns.c | 19 ++- src/pplns.h | 52 +++++- src/store.c | 49 +++++- src/stratum.c | 124 ++++++++------- tests/test_coinbase.c | 132 +++++++++++++++ tests/test_pplns.c | 230 ++++++++++++++++++++++++++- tests/test_pplns_coinbase_regtest.sh | 28 ++-- tests/test_store.c | 61 ++++++- tests/test_stratum.c | 205 ++++++++++++++++++++++++ 13 files changed, 979 insertions(+), 124 deletions(-) diff --git a/src/coinbase.c b/src/coinbase.c index fe6b2d1..3ee1456 100644 --- a/src/coinbase.c +++ b/src/coinbase.c @@ -858,6 +858,12 @@ static int resolve_window_outputs(int64_t value_sats, payout_bytes += cost; out[n].sats = pe->sats; r.paid_sats += pe->sats; + /* Record WHICH payee this was, not just that one more was paid. The + * loop above `continue`s past a payee it cannot pay, so the paid set + * is a subsequence of `payees` rather than a prefix, and a caller + * reconstructing it from paid_count alone credits the wrong miners. + * See coinbase_window_result_t.paid_payee. */ + if (k < COINBASE_MAX_PAYOUT_OUTPUTS) r.paid_payee[k] = 1; n++; r.paid_count++; } diff --git a/src/coinbase.h b/src/coinbase.h index 979b3d1..d2e6e25 100644 --- a/src/coinbase.h +++ b/src/coinbase.h @@ -119,6 +119,24 @@ typedef struct { * per-worker fraction ledger exists to even out over time. */ int64_t redistributed_sats; int64_t fee_sats; /* the operator's fee, and nothing else */ + /* Which payees actually received an output: paid_payee[i] is 1 when + * payees[i] was paid, 0 when the floor or the byte budget dropped it. + * Indices past COINBASE_MAX_PAYOUT_OUTPUTS are not reported. + * + * The counts above are not enough to answer this, and reading paid_count + * as "the first paid_count payees" is wrong. The builder does not stop at + * the first payee it cannot pay -- it skips it and keeps going, because a + * later payee may be a cheaper address type and still fit -- so the paid + * set is a SUBSEQUENCE of the window, never a prefix of it. + * + * A caller that gets this wrong does not misreport a number, it credits + * the wrong miners: the ones the block SKIPPED are recorded as paid and + * sent to the back of the payout queue, and the ones it paid are recorded + * as owed and promoted. That is the exact inversion the queue exists to + * prevent, and it fires on the likeliest case there is -- a reserved + * small claim placed first by pplns_order_claims() and then dropped by + * the floor (LayerTwo-Labs/simplepool#76). */ + uint8_t paid_payee[COINBASE_MAX_PAYOUT_OUTPUTS]; } coinbase_window_result_t; @@ -133,9 +151,12 @@ typedef struct { * fee_bps split every other builder applies. A caller whose arithmetic does * not add up is refused rather than silently underpaying the block. * - * Payees are paid largest first, so the byte budget and the payout floor fall - * on the smallest claims. Those are forfeited to the operator, not carried: - * see coinbase_window_result_t. + * Payees are paid in the order the CALLER gives them, greedily, and the byte + * budget and the payout floor therefore fall on whoever it put last -- + * largest claim first is pplns_order_claims()'s default, not this function's + * rule. Whatever cannot be paid is redistributed across the payees that WERE + * paid, never to the operator, which receives its fee and nothing else. See + * coinbase_window_result_t, and paid_payee for which those were. * * `payout_floor_sats` is clamped UP to COINBASE_DUST_SATS — below the dust * limit an output is not relayable, so there is no floor lower than that to diff --git a/src/config.h b/src/config.h index 9883594..32ccdac 100644 --- a/src/config.h +++ b/src/config.h @@ -128,15 +128,23 @@ typedef struct { * headroom on that. 0 = COINBASE_DEFAULT_MAX_BYTES. */ int coinbase_max_bytes; /* default 1000 */ - /* pplns-coinbase: a claim worth less than this is not paid at all. It is - * forfeited to the operator output, and there is no ledger entry and no - * later settlement -- see the long note in proxy.conf.example. + /* pplns-coinbase: a claim worth less than this is not paid BY THIS BLOCK. + * Its value goes to the other miners in the same window -- not to the + * operator, which receives its fee and nothing else -- and the worker is + * recorded as owed a slot in the payout queue, so a later block whose + * coinbase has room reaches it first. See pplns_fractions in schema.sql. + * + * This comment said the opposite until the mode was measured: the value + * was forfeited to the operator and nothing was carried. That rule paid + * the operator MORE the tighter the coinbase (46%% of the block at a + * 400-byte budget against 2%% at 3000) and excluded the same miners every + * block, because a miner's window share tracks its hashrate. See the long + * note at the redistribution in coinbase.c + * (LayerTwo-Labs/simplepool#76). * * Deliberate policy, not a rounding artefact: coinbase-direct pays out of * the block itself, so every extra output is bytes an operator may not - * have. Rather than carry a debt no one can see, the floor is stated up - * front and a miner too small to clear it is better off solo mining. - * Clamped up to COINBASE_DUST_SATS (546); below that no output is + * have. Clamped up to COINBASE_DUST_SATS (546); below that no output is * relayable anyway. */ int64_t pplns_payout_floor_sats; /* default 546 (dust) */ diff --git a/src/main.c b/src/main.c index 099f572..73f94ef 100644 --- a/src/main.c +++ b/src/main.c @@ -207,6 +207,35 @@ static void on_window_fractions_cb(void *ctx, const char *block_hash, rc, block_hash ? block_hash : "?"); } +/* The tightest coinbase byte ceiling any connection can be subject to. + * + * The window and its payment order are decided ONCE, per template, on the tip + * watcher — but the ceiling that cuts that order short is per-listener, and a + * rented port's is deliberately far tighter than a home port's. One order has + * to serve both, so the reservation has to be sized for the tightest of them. + * + * The two errors are not symmetric. Size it from a generous ceiling and the + * tight port reserves more positions than it has slots, so every slot it does + * have goes to the queue: the largest claims are paid nothing, immediately + * re-enter the queue themselves, and the rotation oscillates instead of + * rotating. Size it from the tight one and the generous port simply reserves + * fewer slots than it could have — it rotates more slowly and nothing else + * changes. So: the minimum, and never the configured server-wide figure on + * its own (LayerTwo-Labs/simplepool#76). + * + * bind_port is always served on the server-wide ceiling, so that is always in + * the running. */ +static size_t tightest_coinbase_budget(const proxy_config_t *cfg) { + size_t b = cfg->coinbase_max_bytes > 0 + ? (size_t)cfg->coinbase_max_bytes + : (size_t)COINBASE_DEFAULT_MAX_BYTES; + for (int i = 0; i < cfg->listener_count; ++i) { + int lb = cfg->listeners[i].max_coinbase_bytes; + if (lb > 0 && (size_t)lb < b) b = (size_t)lb; + } + return b; +} + /* Snapshot the PPLNS window onto a freshly built job, for pplns-coinbase. * * The window is taken from the template that is about to go out, so the @@ -319,43 +348,95 @@ static int attach_pplns_window(store_t *store, const proxy_config_t *cfg, claims[i].owed_fraction = win[i].owed_fraction; } - /* Decide who gets the slots before deciding what they are worth. + /* What each claim is worth, BEFORE deciding who gets a slot. + * + * This used to run the other way round -- order, then split the reordered + * claims -- which cost two things. The splitter's truncation remainder + * lands on claims[0], which it documents as the largest claim, and after a + * reordering that was whoever the queue had promoted. And, more to the + * point, the ordering had no idea what anybody was worth, so it could + * reserve a slot for a claim the floor was about to drop. + * + * Splitting first fixes both. The split is proportional, so the order does + * not change a single amount -- only which index carries the remainder -- + * and the permutation below moves the payees with their claims. */ + coinbase_payee_t by_claim[COINBASE_MAX_PAYOUT_OUTPUTS]; + pplns_split_t split; + char serr[256] = {0}; + if (pplns_split_window(value, cfg->fee_bps, cfg->operator_address[0] != 0, + claims, n, total, cfg->pplns_payout_floor_sats, + by_claim, COINBASE_MAX_PAYOUT_OUTPUTS, + &split, serr, sizeof serr) < 0) { + LOG_WARN("pplns-coinbase: cannot split this block across the window: " + "%s", serr); + return -1; + } + + /* If NOTHING clears the floor there is no coinbase to render from this + * window at all -- the builder refuses a window it cannot pay anybody + * from, on the same reasoning as the splitter above. Catch it here, where + * it can be said once with a cause, rather than letting it surface as a + * render failure on every connection for every job: that is a pool that + * publishes no work while logging a warning per miner per template, which + * is precisely the shape of failure the template-reward check above exists + * to avoid. * - * A coinbase has room for a bounded number of payouts, and paying the - * largest claims first — which is what the builder used to do on its own — - * hands the same addresses the same slots every block, because a large - * miner's share of the window beats any priority a small one can - * accumulate. A fraction of the slots is therefore reserved for whoever - * has waited longest. Costs no bytes, changes nobody's total, changes only - * how often people are paid. */ + * Reachable without anything exotic. A small block reward divided across + * enough miners puts every claim under the dust limit -- 5000 sats across + * 20 miners is 250 each -- and a configured floor reaches it far sooner. + * The numbers are in the message because the fix is arithmetic the + * operator can do: raise the reward, lower the floor, or accept fewer + * miners. */ + if (split.below_floor >= n) { + LOG_WARN("pplns-coinbase: not one of the %zu miner(s) in the window " + "clears the %lld-sat payout floor — %lld sats split %zu ways " + "pays nobody, so this template is skipped and NO WORK IS " + "PUBLISHED from it. Lower pplns_payout_floor_sats, or accept " + "fewer miners in the window (pplns_window_diff_multiple).", + n, (long long)(cfg->pplns_payout_floor_sats < COINBASE_DUST_SATS + ? COINBASE_DUST_SATS + : cfg->pplns_payout_floor_sats), + (long long)split.payable_sats, n); + return -1; + } + + /* Now decide who gets the slots the coinbase has room for. + * + * Paying the largest claims first — which is what the builder used to do + * on its own — hands the same addresses the same slots every block, + * because a large miner's share of the window beats any priority a small + * one can accumulate. A fraction of the slots is therefore reserved for + * whoever has waited longest. Costs no bytes, changes nobody's total, + * changes only how often people are paid. + * + * `by_claim` goes in so the reservation is not spent on a claim the floor + * is about to drop: it could not be paid from a reserved slot either, and + * a permanently sub-floor miner's owed_fraction only ever grows, so it + * would crowd out the byte-capped miners the rotation is for. */ /* The real addresses, in claim order, so the estimate charges each output * what it costs instead of assuming P2WPKH. */ const char *addrs[COINBASE_MAX_PAYOUT_OUTPUTS]; for (size_t i = 0; i < n; ++i) addrs[i] = claims[i].payout_address; size_t expected_slots = coinbase_expected_payout_slots( - (size_t)cfg->coinbase_max_bytes, t->coinbasetxn_hex, addrs, n); + tightest_coinbase_budget(cfg), t->coinbasetxn_hex, addrs, n); size_t order[COINBASE_MAX_PAYOUT_OUTPUTS]; - if (pplns_order_claims(claims, n, expected_slots, order) < 0) { + if (pplns_order_claims(claims, n, expected_slots, by_claim, + cfg->pplns_payout_floor_sats, order) < 0) { LOG_WARN("pplns-coinbase: could not order the window for payment"); return -1; } - pplns_claim_t ordered[COINBASE_MAX_PAYOUT_OUTPUTS]; - for (size_t i = 0; i < n; ++i) ordered[i] = claims[order[i]]; + /* Permute the payees and their workers into payment order together. The + * amounts are unchanged by this -- a permutation moves who is paid first, + * never what anybody is paid -- and the pair has to stay aligned because + * stratum.c reads the worker out of the same index the builder reports as + * paid or skipped. */ coinbase_payee_t payees[COINBASE_MAX_PAYOUT_OUTPUTS]; - pplns_split_t split; - char serr[256] = {0}; - if (pplns_split_window(value, cfg->fee_bps, cfg->operator_address[0] != 0, - ordered, n, total, cfg->pplns_payout_floor_sats, - payees, COINBASE_MAX_PAYOUT_OUTPUTS, - &split, serr, sizeof serr) < 0) { - LOG_WARN("pplns-coinbase: cannot split this block across the window: " - "%s", serr); - return -1; - } - int64_t worker_ids[COINBASE_MAX_PAYOUT_OUTPUTS]; - for (size_t i = 0; i < n; ++i) worker_ids[i] = ordered[i].worker_id; + for (size_t i = 0; i < n; ++i) { + payees[i] = by_claim[order[i]]; + worker_ids[i] = claims[order[i]].worker_id; + } if (stratum_job_set_window(job, payees, worker_ids, n) < 0) { LOG_WARN("pplns-coinbase: could not attach the window to the job"); @@ -382,9 +463,11 @@ static int attach_pplns_window(store_t *store, const proxy_config_t *cfg, if (below > 0) { LOG_INFO("pplns-coinbase: %zu of %zu miner(s) in the window are " "below the %lld-sat payout floor and will earn NOTHING " - "from the next block — their share is forfeited to the " - "operator, not carried. Tell them, or lower " - "pplns_payout_floor_sats.", + "from the next block — their share goes to the miners " + "the block CAN pay, and they move to the front of the " + "payout queue for a later one. Nothing reaches the " + "operator, which takes its fee and nothing else. Tell " + "them, or lower pplns_payout_floor_sats.", below, n, (long long)floor_sats); } else { LOG_INFO("pplns-coinbase: every miner in the window clears the " @@ -1231,11 +1314,14 @@ int main(int argc, char **argv) { * saw it stated cannot disclose it to the miners it costs. */ if (strcmp(cfg.pool_mode, "pplns-coinbase") == 0) { LOG_INFO("pplns-coinbase: payout floor %lld sats — a miner whose " - "share of a block is worth less than that is NOT PAID, " - "and the amount goes to the operator. Nothing is carried " - "and nothing settles later. The dashboard states this to " - "miners; publish it on your pool page too.", - (long long)cfg.pplns_payout_floor_sats); + "share of a block is worth less than that is NOT PAID BY " + "THAT BLOCK. The amount goes to the other miners in the " + "same window, never to the operator, and the miner moves " + "to the front of the payout queue for a later block. It " + "is a rotation, not a balance: the pool holds nothing " + "against it and no payment settles later. The dashboard " + "states this to miners; publish it on your pool page " + "too.", (long long)cfg.pplns_payout_floor_sats); } /* Publish the ports so the dashboard can tell a miner which one to * dial. Labels are constrained to [A-Za-z0-9_-] at config parse time, diff --git a/src/pplns.c b/src/pplns.c index 1e39ec5..0d88c6f 100644 --- a/src/pplns.c +++ b/src/pplns.c @@ -110,10 +110,17 @@ static int rank_desc(const void *a, const void *b) { } int pplns_order_claims(const pplns_claim_t *claims, size_t n_claims, - size_t expected_slots, size_t *order) + size_t expected_slots, + const coinbase_payee_t *amounts, + int64_t payout_floor_sats, + size_t *order) { if (!claims || !order || n_claims == 0) return -1; + /* Same clamp as the builder's, so "can this be paid" has one answer. */ + int64_t floor_sats = payout_floor_sats < COINBASE_DUST_SATS + ? COINBASE_DUST_SATS : payout_floor_sats; + rank_t *by_size = calloc(n_claims, sizeof *by_size); rank_t *by_owed = calloc(n_claims, sizeof *by_owed); char *placed = calloc(n_claims, 1); @@ -143,8 +150,14 @@ int pplns_order_claims(const pplns_claim_t *claims, size_t n_claims, * largest-first, as it was before the ledger existed. */ for (size_t k = 0; k < n_claims && n < reserved; ++k) { if (by_owed[k].key <= 0.0) break; - order[n++] = by_owed[k].idx; - placed[by_owed[k].idx] = 1; + size_t i = by_owed[k].idx; + /* A claim the floor will drop cannot be paid from a reserved slot any + * more than from an unreserved one, so giving it one pays nobody and + * denies a miner that could have used it. `continue`, not `break`: + * the next-longest-waiting claim behind it may well be payable. */ + if (amounts && amounts[i].sats < floor_sats) continue; + order[n++] = i; + placed[i] = 1; } /* Then everyone else, largest claim first. */ for (size_t k = 0; k < n_claims; ++k) { diff --git a/src/pplns.h b/src/pplns.h index 35ec681..eba7667 100644 --- a/src/pplns.h +++ b/src/pplns.h @@ -61,25 +61,53 @@ typedef struct { * `owed_fraction`, longest-waiting first. * * `expected_slots` is how many payouts the caller believes will fit. It only - * decides how many slots are reserved; getting it wrong changes the fairness - * of the rotation, never the arithmetic — everyone in `order` is still paid - * their own claim, and anyone the budget cuts is still redistributed. + * decides how many slots are reserved. Erring LOW is safe -- a smaller + * reservation just rotates more slowly -- and erring high is not: reserve + * more positions than the coinbase has room for and every slot it does have + * goes to the queue, so the largest claims are paid nothing and immediately + * re-enter the queue themselves. A caller with several coinbase budgets in + * play (a per-listener ceiling) must therefore size this from the TIGHTEST + * of them. + * + * `amounts` is what each claim is worth, aligned with `claims`, as + * pplns_split_window() computed it; NULL means "assume every claim is + * payable". A claim worth less than `payout_floor_sats` (clamped up to + * COINBASE_DUST_SATS, as everywhere else) cannot be paid by this block at any + * position, so it is not given a reserved slot -- it would hold the slot + * against a miner that could actually use it, and a permanently sub-floor + * miner accumulates `owed_fraction` for ever while a byte-capped one is paid + * and resets, so over a long enough run the miners who can NEVER be paid + * crowd out the ones the rotation exists for (LayerTwo-Labs/simplepool#76). + * It still appears in `order` at its normal largest-first position: the + * permutation always covers every claim, because the ledger and the + * redistribution both need the ones that were skipped. * * Returns 0, or negative on bad input. */ int pplns_order_claims(const pplns_claim_t *claims, size_t n_claims, - size_t expected_slots, size_t *order); + size_t expected_slots, + const coinbase_payee_t *amounts, + int64_t payout_floor_sats, + size_t *order); typedef struct { int64_t fee_sats; /* the operator's cut, off the top */ int64_t payable_sats; /* what the payees must sum to, exactly */ /* How many claims are worth less than payout_floor_sats and will - * therefore be forfeited to the operator by the builder. + * therefore be skipped by the builder, their value going to the miners it + * could pay. (Not to the operator: that was the rule until #76, and the + * long note at the redistribution in coinbase.c has the measurement that + * ended it.) * * Computed here rather than left for the builder to discover because the * operator has to be told BEFORE a block makes it real -- a count after * the fact reports a loss, a count now is something they can act on. The * builder applies the floor itself; this only predicts it, using the same - * clamp so the two cannot disagree. */ + * clamp so the two cannot disagree. + * + * When it reaches n_claims the builder will refuse the window outright: + * it has nobody to pay. A caller must not publish a template it predicts + * that for -- the refusal lands per connection, per job, and the pool + * simply stops serving work. See attach_pplns_window() in main.c. */ size_t below_floor; } pplns_split_t; @@ -94,7 +122,17 @@ typedef struct { * less than it may forfeits the difference to nobody. * * `claims` must be ordered largest-difficulty-first, as store_pplns_window() - * returns them, so the remainder lands on the strongest claim. + * returns them, so the truncation remainder lands on the strongest claim. + * Split FIRST and reorder afterwards: pplns_order_claims() needs these amounts + * to know which claims the floor will drop, and a reordering does not change + * a single one of them -- only which index carries the remainder. Splitting + * the reordered claims instead put the remainder on whoever the queue had + * promoted, which is at most a satoshi per claim but is also not what this + * says it does. + * + * Nothing downstream may assume out[0] is the largest payee once the caller + * has reordered: coinbase.c does not, and scans for the largest surviving + * output when it redistributes. * * `total_diff` is the window's total as the store reported it, passed in * rather than re-summed here. diff --git a/src/store.c b/src/store.c index 4f6f583..bbf81b8 100644 --- a/src/store.c +++ b/src/store.c @@ -1698,9 +1698,25 @@ int store_stage_block_fractions(store_t *s, const char *block_hash, * writing it would put the ledger permanently out of balance -- the one * invariant that makes "nobody is owed money" checkable. Floating point * means "zero" is a tolerance, sized well below the smallest rotation - * anyone could notice. */ + * anyone could notice. + * + * Summed over the rows that will actually be WRITTEN, not over everything + * passed in. A delta whose worker_id is unknown has no row to live in -- + * pplns_claim_t documents 0 as exactly that -- and the loop below skips + * it. Checking the total first and skipping afterwards would let a set + * that balances only WITH the orphan reach the table without it, which is + * the imbalance this check exists to prevent, arrived at by PASSING the + * check rather than failing it. */ double sum = 0.0; - for (size_t i = 0; i < n; ++i) sum += deltas[i].delta; + size_t writable = 0; + for (size_t i = 0; i < n; ++i) { + if (deltas[i].worker_id <= 0) continue; + sum += deltas[i].delta; + writable++; + } + /* Nothing to stage. Not an error: no rotation was recorded and none was + * lost, because nothing in the set names a worker. */ + if (writable == 0) return 0; if (sum > 1e-9 || sum < -1e-9) { if (errbuf && errlen) snprintf(errbuf, errlen, @@ -1756,6 +1772,35 @@ int store_settle_block_fractions(store_t *s, int *out_applied, return -1; } + /* Is there anything staged at all? A read, outside any transaction, and + * on the overwhelmingly common path the answer is no. + * + * Worth asking first because this runs on EVERY reconcile pass in EVERY + * mode -- a pool that has never been pplns-coinbase still comes through + * here once per tip -- and the transaction below is BEGIN IMMEDIATE, which + * takes the database's write lock and txn_mu with it. That stalls the + * commit thread's share batch for the length of a write transaction, to + * settle a table that is empty and always will be. + * + * Racy by construction and harmlessly so: a row staged between this check + * and the next statement is simply settled by the next pass, which is + * already the cadence the whole mechanism runs at. It can only ever cause + * a settlement to happen one tip later, never one that should not have. */ + { + sqlite3_stmt *any = NULL; + int have = 0; + if (sqlite3_prepare_v2(s->db, + "SELECT 1 FROM pplns_pending_fractions LIMIT 1", + -1, &any, NULL) != SQLITE_OK) { + if (errbuf && errlen) snprintf(errbuf, errlen, "%s", sqlite3_errmsg(s->db)); + atomic_fetch_add(&s->pg_errors, 1); + return -2; + } + have = (sqlite3_step(any) == SQLITE_ROW); + sqlite3_finalize(any); + if (!have) return 0; + } + /* One transaction for the whole settlement. A partially applied block * would leave the ledger not summing to zero, and unlike a failed payout * there is no later pass that could notice: the pending rows are gone. */ diff --git a/src/stratum.c b/src/stratum.c index d1fc4b1..c44c1f1 100644 --- a/src/stratum.c +++ b/src/stratum.c @@ -777,6 +777,22 @@ static int j_payees_missing(const stratum_job_t *job) { return !job->payees || job->n_payees == 0; } +/* Did payees[i] actually receive a coinbase output? + * + * Never answerable from res.paid_count. The builder skips a payee it cannot + * pay and keeps going -- a later one may be a cheaper address type and still + * fit -- so the paid set is a SUBSEQUENCE of the window, not a prefix of it. + * Reading it as a prefix does not misreport a number, it names the wrong + * miners: the block's skipped payee gets recorded as paid and sent to the back + * of the payout queue, and a payee that WAS paid gets recorded as owed and + * promoted ahead of it. The floor drops the smallest claim, and + * pplns_order_claims() deliberately puts a reserved small claim first, so the + * dropped payee sits at index 0 in the commonest case there is + * (LayerTwo-Labs/simplepool#76). */ +static int cbwin_was_paid(const coinbase_window_result_t *res, size_t i) { + return i < COINBASE_MAX_PAYOUT_OUTPUTS && res->paid_payee[i]; +} + /* The byte ceiling that applies to THIS connection: its listener's, or the * server-wide one when the listener did not set its own. * @@ -791,6 +807,39 @@ static size_t conn_coinbase_budget(const stratum_server_t *s, return s->cfg.max_coinbase_bytes; } +/* The solo shape: a coinbase paying THIS connection's miner, plus the + * operator fee. Its own function because two different modes need it and a + * verbatim second copy is how the two drift -- solo reaches it because that is + * what solo is, and pplns-coinbase reaches it when a job carries no window + * yet. It used to be a `goto` into an `else if (0)` block sitting beside an + * identical live branch, which meant a fix to one would silently not reach the + * other. */ +static int render_finder_coinbase(stratum_server_t *s, stratum_conn_t *c, + const stratum_job_t *job, + coinbase_parts_t *parts, + char *err, size_t errlen) { + if (job->coinbasetxn_hex) { + /* Backend dictated the coinbase (e.g. CUSF enforcer): build from it, + * redirecting the reward output to this miner and preserving the + * mandatory commitment outputs. The witness commitment is already in + * the server's coinbase, so job->wc_hex is not used here. */ + return coinbase_build_from_template(job->coinbasetxn_hex, + c->payout_address, + s->cfg.operator_address, + s->cfg.fee_bps, + s->cfg.coinbase_tag, + job->en1_size, job->en2_size, + parts, NULL, NULL, NULL, + err, errlen); + } + return coinbase_build_split(job->height, job->value_sats, + c->payout_address, + s->cfg.operator_address, s->cfg.fee_bps, + job->wc_hex, s->cfg.coinbase_tag, + job->en1_size, job->en2_size, + parts, NULL, NULL, err, errlen); +} + static int conn_render_coinbase(stratum_server_t *s, stratum_conn_t *c, const stratum_job_t *job) { if (!c->authorized || c->payout_address[0] == '\0') return -1; @@ -800,23 +849,19 @@ 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.coinbase_pays_window) { + /* Bootstrap: a pplns-coinbase job with no window has nobody to pay, so it + * renders the solo shape instead — this connection's own miner. See + * attach_pplns_window() in main.c: with no prior work the only party with + * a claim on the block is whoever finds it, and refusing to render would + * deadlock a new pool for ever (no coinbase, so no shares, so no window). */ + if (s->cfg.coinbase_pays_window && !j_payees_missing(job)) { /* pplns-coinbase: the block pays the window that produced it, one * output per miner, and the pool never receives the reward. The - * window was snapshotted onto the job when the template was built — - * every connection therefore renders the SAME coinbase, exactly as - * the pooled modes do, because the outputs live in cb2 and only the - * extranonce differs per connection. */ - if (j_payees_missing(job)) { - /* Bootstrap: no shares have been accepted yet, so there is no - * window to pay. Fall through to the solo shape — this - * connection's own coinbase, paying this miner. See - * attach_pplns_window() in main.c: with no prior work the only - * party with a claim on the block is whoever finds it, and - * refusing to render here instead would deadlock a new pool - * forever (no coinbase, so no shares, so no window). */ - goto render_solo; - } + * window was snapshotted onto the job when the template was built, so + * every connection pays the same miners in the same order — but not + * necessarily the same NUMBER of them, because the byte ceiling is + * per-listener and cuts the tail of that order at a different point + * on a rented port than on a home one. */ if (job->coinbasetxn_hex) { rc = coinbase_build_window_from_template( job->coinbasetxn_hex, job->payees, job->n_payees, @@ -854,43 +899,10 @@ static int conn_render_coinbase(stratum_server_t *s, stratum_conn_t *c, job->en1_size, job->en2_size, &parts, NULL, NULL, err, sizeof err); } - } else if (0) { -render_solo: - /* Reached either by solo mode or by a pplns-coinbase job that has no - * window yet. Both pay this one connection's miner. */ - if (job->coinbasetxn_hex) { - rc = coinbase_build_from_template(job->coinbasetxn_hex, - c->payout_address, - s->cfg.operator_address, s->cfg.fee_bps, - s->cfg.coinbase_tag, - job->en1_size, job->en2_size, - &parts, NULL, NULL, NULL, err, sizeof err); - } else { - rc = coinbase_build_split(job->height, job->value_sats, - c->payout_address, - s->cfg.operator_address, s->cfg.fee_bps, - job->wc_hex, s->cfg.coinbase_tag, - job->en1_size, job->en2_size, - &parts, NULL, NULL, err, sizeof err); - } - } else if (job->coinbasetxn_hex) { - /* Backend dictated the coinbase (e.g. CUSF enforcer): build from it, - * redirecting the reward output to this miner and preserving the - * mandatory commitment outputs. The witness commitment is already in - * the server's coinbase, so job->wc_hex is not used here. */ - rc = coinbase_build_from_template(job->coinbasetxn_hex, - c->payout_address, - s->cfg.operator_address, s->cfg.fee_bps, - s->cfg.coinbase_tag, - job->en1_size, job->en2_size, - &parts, NULL, NULL, NULL, err, sizeof err); } else { - rc = coinbase_build_split(job->height, job->value_sats, - c->payout_address, - s->cfg.operator_address, s->cfg.fee_bps, - job->wc_hex, s->cfg.coinbase_tag, - job->en1_size, job->en2_size, - &parts, NULL, NULL, err, sizeof err); + /* Solo, and the pplns-coinbase bootstrap: this connection's own + * miner. One implementation, shared. */ + rc = render_finder_coinbase(s, c, job, &parts, err, sizeof err); } if (rc < 0) { LOG_WARN("stratum: coinbase render failed for %s: %s", @@ -2227,13 +2239,11 @@ static int submit_with_job(stratum_server_t *s, stratum_conn_t *c, cJSON *id, int64_t entitled_total = 0, survivors_own = 0; for (size_t i = 0; i < job->n_payees; ++i) { entitled_total += job->payees[i].sats; - if (i < res.paid_count) survivors_own += job->payees[i].sats; + if (cbwin_was_paid(&res, i)) + survivors_own += job->payees[i].sats; } if (entitled_total > 0 && survivors_own > 0) { - /* The builder pays in job order, so the first paid_count - * payees are the ones that got an output. - * - * `got` is a survivor's share of what was actually paid + /* `got` is a survivor's share of what was actually paid * out, which after redistribution is the WHOLE payable * amount -- so it is their claim over the survivors' claims, * not over the window's. Dividing by res.paid_sats instead @@ -2244,7 +2254,7 @@ static int submit_with_job(stratum_server_t *s, stratum_conn_t *c, cJSON *id, nd < COINBASE_MAX_PAYOUT_OUTPUTS; ++i) { double entitled = (double)job->payees[i].sats / (double)entitled_total; - double got = i < res.paid_count + double got = cbwin_was_paid(&res, i) ? (double)job->payees[i].sats / (double)survivors_own : 0.0; diff --git a/tests/test_coinbase.c b/tests/test_coinbase.c index 8c9154b..fe60671 100644 --- a/tests/test_coinbase.c +++ b/tests/test_coinbase.c @@ -828,6 +828,136 @@ static void window_outputs(const coinbase_parts_t *p, size_t en_total, *sum_out = sum; } +/* Which of WA/WB/WC an output pays, or -1. Reads the transaction, so the + * assertion is about what the block does rather than what the builder said. */ +static int window_payee_at(const coinbase_parts_t *p, uint64_t idx) { + static const char *addrs[3] = { WA, WB, WC }; + uint8_t spk[3][64]; size_t spk_len[3]; + for (int i = 0; i < 3; ++i) + assert(coinbase_address_to_script(addrs[i], spk[i], sizeof spk[i], + &spk_len[i], NULL, 0) == 0); + const uint8_t *b = p->cb2; + size_t off = 4; + uint64_t n = b[off++]; + for (uint64_t i = 0; i < n; ++i) { + off += 8; + size_t sl = b[off++]; + if (i == idx) { + for (int k = 0; k < 3; ++k) + if (sl == spk_len[k] && memcmp(b + off, spk[k], sl) == 0) return k; + return -1; + } + off += sl; + } + return -1; +} + +/* The paid set is a SUBSEQUENCE of the window, not a prefix of it. + * + * The builder skips a payee it cannot pay and keeps going, because a later one + * may be a cheaper address type and still fit. So `paid_count` says how many + * were paid and says nothing about which, and a caller reconstructing the set + * as "the first paid_count payees" gets a different set entirely the moment + * anything but the tail is dropped. + * + * That is not a reporting nit. stratum.c feeds exactly this set into the + * payout queue, so getting it wrong records the SKIPPED miner as paid -- + * sending it to the back of the queue for a payment it never received -- and + * the paid one as owed. The case that triggers it is the likeliest one there + * is: pplns_order_claims() deliberately puts a reserved small claim FIRST, and + * a small claim is exactly what the floor drops (LayerTwo-Labs/simplepool#76). + */ +static void test_the_result_names_which_payees_were_paid(void) { + coinbase_parts_t parts; char err[256]; + coinbase_window_result_t res; + /* payee[0] is below the dust floor; the two behind it are not. */ + const coinbase_payee_t payees[] = { + { WA, 100LL }, + { WB, 50000000LL }, + { WC, 49999900LL }, + }; + assert(coinbase_build_window(800000, 100000000LL, payees, 3, + NULL, 0, NULL, NULL, 4, 8, + 0, 0, &parts, &res, err, sizeof err) == 0); + assert(res.paid_count == 2); + assert(res.dropped_below_floor == 1); + + /* The transaction pays WB and WC. It does NOT pay WA, which is what + * "the first paid_count payees" would have claimed. */ + uint64_t n = 0; int64_t sum = 0; + window_outputs(&parts, 12, &n, &sum); + assert(n == 2); + assert(window_payee_at(&parts, 0) == 1); /* WB */ + assert(window_payee_at(&parts, 1) == 2); /* WC */ + + /* And the mask says so, index by index. */ + assert(res.paid_payee[0] == 0); + assert(res.paid_payee[1] == 1); + assert(res.paid_payee[2] == 1); + coinbase_parts_free(&parts); + + /* The same, one level harder: a drop in the MIDDLE, and a drop that the + * byte budget rather than the floor causes. A budget with room for two + * P2WPKH payouts, three payees, the middle one under the floor -- so the + * survivors are index 0 and index 2 and the prefix reading is wrong at + * both ends. */ + const coinbase_payee_t mid[] = { + { WA, 40000000LL }, + { WB, 500LL }, /* under dust */ + { WC, 59999500LL }, + }; + assert(coinbase_build_window(800000, 100000000LL, mid, 3, + NULL, 0, NULL, NULL, 4, 8, + 0, 0, &parts, &res, err, sizeof err) == 0); + assert(res.paid_count == 2); + assert(res.paid_payee[0] == 1); + assert(res.paid_payee[1] == 0); + assert(res.paid_payee[2] == 1); + assert(window_payee_at(&parts, 0) == 0); /* WA */ + assert(window_payee_at(&parts, 1) == 2); /* WC */ + coinbase_parts_free(&parts); + + /* Nothing dropped: every payee is marked, and only those. */ + const coinbase_payee_t all[] = { { WA, 60000000LL }, { WB, 40000000LL } }; + assert(coinbase_build_window(800000, 100000000LL, all, 2, + NULL, 0, NULL, NULL, 4, 8, + 0, 0, &parts, &res, err, sizeof err) == 0); + assert(res.paid_payee[0] == 1 && res.paid_payee[1] == 1); + assert(res.paid_payee[2] == 0); + coinbase_parts_free(&parts); + printf("ok: the result names which payees were paid, not just how many\n"); +} + +/* The same mask, on the drivechain path. The two builders share one resolver + * precisely so they cannot disagree; this pins that the reporting is shared + * too, because stratum.c reads it from whichever one ran. */ +static void test_the_template_builder_reports_the_same_paid_set(void) { + coinbase_parts_t parts; char err[256]; + coinbase_window_result_t res; + int64_t reward = 0; + assert(coinbase_template_reward(ENF_COINBASE_HEX, &reward) == 0); + assert(reward > 200000); + + /* payee[0] is under the dust floor; the two behind it share the rest. */ + int64_t b = (reward - 100) / 2; + const coinbase_payee_t payees[] = { + { WA, 100LL }, + { WB, b }, + { WC, reward - 100 - b }, + }; + assert(coinbase_build_window_from_template(ENF_COINBASE_HEX, payees, 3, + NULL, 0, NULL, 4, 4, 0, 0, + &parts, NULL, &res, + err, sizeof err) == 0); + assert(res.paid_count == 2); + assert(res.dropped_below_floor == 1); + assert(res.paid_payee[0] == 0); + assert(res.paid_payee[1] == 1); + assert(res.paid_payee[2] == 1); + coinbase_parts_free(&parts); + printf("ok: the template builder reports the same paid set\n"); +} + static void test_window_pays_each_miner_its_own_output(void) { coinbase_parts_t parts; char err[256]; coinbase_window_result_t res; @@ -1514,6 +1644,8 @@ int main(void) { test_both_window_builders_split_identically(); test_window_from_template_preserves_commitments(); test_window_pays_each_miner_its_own_output(); + test_the_result_names_which_payees_were_paid(); + test_the_template_builder_reports_the_same_paid_set(); test_a_split_that_does_not_add_up_is_refused(); test_a_payee_below_the_floor_is_shared_out_not_given_to_the_operator(); test_the_operator_cannot_profit_by_shrinking_the_coinbase(); diff --git a/tests/test_pplns.c b/tests/test_pplns.c index 6d58df8..1d71c6e 100644 --- a/tests/test_pplns.c +++ b/tests/test_pplns.c @@ -305,6 +305,63 @@ static void test_the_builder_accepts_what_the_splitter_produces(void) { * rounding artefact: dividing by it would pay out MORE than the block holds. * Refuse rather than hand the builder a split it will reject on every * connection. */ +/* A floor that nobody clears stalls the POOL, not just a block. + * + * The builder refuses a window in which every claim is below the floor -- it + * has nothing to pay and paying the operator the whole block would be the + * worst available outcome. That refusal happens per connection, per job, at + * render time, so the visible symptom is a pool that publishes no work and + * repeats a warning, with no single event to trace it to. It is the exact + * failure the template-reward check upstream was added to prevent, arrived at + * from the other direction. + * + * Nothing exotic is needed to reach it: a small block reward spread across + * enough miners puts every claim under the 546-sat dust floor. 5000 sats + * across 20 miners is 250 each. A caller therefore has to notice BEFORE + * publishing, which is what below_floor is for -- and this pins that the + * splitter's prediction and the builder's refusal agree on the same window, + * so a caller checking one is protected from the other. */ +static void test_a_window_nobody_clears_is_predicted_and_refused(void) { + enum { N = 20 }; + pplns_claim_t claims[N]; + coinbase_payee_t out[N]; + pplns_split_t r; + char err[256] = {0}; + for (int i = 0; i < N; ++i) { + claims[i].payout_address = (i % 2) ? A : B; + claims[i].difficulty = 1.0; + claims[i].worker_id = i + 1; + claims[i].owed_fraction = 0.0; + } + /* 5000 sats, no fee, 20 equal claims: 250 sats each, under dust. */ + CHECK(pplns_split_window(5000, 0, 0, claims, N, (double)N, 0, + out, N, &r, err, sizeof err) == 0); + CHECK(r.payable_sats == 5000); + /* The splitter predicts it: every single claim is below the floor. */ + CHECK(r.below_floor == (size_t)N); + + /* And the builder refuses exactly that window, so a caller that published + * it would render nothing on every connection. */ + coinbase_parts_t parts; + char berr[256] = {0}; + CHECK(coinbase_build_window(800000, 5000, out, N, NULL, 0, NULL, NULL, + 4, 8, 0, 0, &parts, NULL, + berr, sizeof berr) < 0); + CHECK(strstr(berr, "no payee fits") != NULL); + + /* One claim large enough to clear the floor is all it takes: the + * prediction drops to N-1 and the builder builds. */ + claims[0].difficulty = 100.0; + CHECK(pplns_split_window(5000, 0, 0, claims, N, 100.0 + (N - 1), 0, + out, N, &r, err, sizeof err) == 0); + CHECK(r.below_floor == (size_t)(N - 1)); + CHECK(coinbase_build_window(800000, 5000, out, N, NULL, 0, NULL, NULL, + 4, 8, 0, 0, &parts, NULL, + berr, sizeof berr) == 0); + coinbase_parts_free(&parts); + printf("ok: a window nobody clears is predicted before it is refused\n"); +} + static void test_a_window_total_that_is_too_small_is_refused(void) { const pplns_claim_t claims[] = { { A, 60.0, 0, 0.0 }, { B, 60.0, 0, 0.0 } }; coinbase_payee_t out[2]; @@ -342,7 +399,7 @@ static void test_with_nothing_owed_the_order_is_largest_first(void) { c[i].difficulty = sizes[i]; c[i].owed_fraction = 0.0; } size_t order[5]; - CHECK(pplns_order_claims(c, 5, 5, order) == 0); + CHECK(pplns_order_claims(c, 5, 5, NULL, 0, order) == 0); CHECK(c[order[0]].difficulty == 50); CHECK(c[order[1]].difficulty == 30); CHECK(c[order[2]].difficulty == 20); @@ -373,7 +430,7 @@ static void test_a_long_waiting_small_miner_reaches_a_slot(void) { c[N - 1].owed_fraction = 0.004; size_t order[N]; - CHECK(pplns_order_claims(c, N, SLOTS, order) == 0); + CHECK(pplns_order_claims(c, N, SLOTS, NULL, 0, order) == 0); /* One slot of four is reserved, and it goes to the waiting miner. */ CHECK(order[0] == N - 1); /* The rest of the slots still go to the largest claims, in order, so the @@ -384,6 +441,167 @@ static void test_a_long_waiting_small_miner_reaches_a_slot(void) { printf("ok: a long-waiting small miner reaches a reserved slot\n"); } +/* Sizing `expected_slots` too HIGH starves the largest claims outright. + * + * The reservation is a fraction of the slots the caller says the coinbase will + * have. Tell it 40 when the coinbase fits 9 and it reserves 10 positions at + * the head of the order — more than the block has room for — so every output + * goes to the queue and not one of the largest claims is paid. They then enter + * the queue themselves and the rotation oscillates instead of rotating. + * + * The opposite error costs nothing but time: reserve too few and the queue + * moves more slowly. That asymmetry is the whole reason main.c sizes this from + * the TIGHTEST byte ceiling any listener can impose rather than the + * server-wide one — a rented port's ceiling is deliberately far lower, and one + * payment order has to serve every port (LayerTwo-Labs/simplepool#76). */ +static void test_oversizing_the_slot_estimate_starves_the_largest_claims(void) { + enum { N = 60 }; + /* 400 bytes fits ten payouts. A generous ceiling's estimate reserves + * fifteen, so five of them do not exist and the ten that do are all + * queue picks. */ + const size_t TIGHT = 400; + pplns_claim_t c[N]; + double total = 0.0; + for (int i = 0; i < N; ++i) { + c[i].payout_address = A; c[i].worker_id = i + 1; + c[i].difficulty = 1000.0 / (i + 1); + /* The tail has been waiting; the big claims have not. */ + c[i].owed_fraction = i >= N / 2 ? 0.001 * (i + 1) : 0.0; + total += c[i].difficulty; + } + coinbase_payee_t by_claim[N]; + pplns_split_t split; + char err[256] = {0}; + CHECK(pplns_split_window(5000000000LL, 0, 0, c, N, total, 546, + by_claim, N, &split, err, sizeof err) == 0); + CHECK(split.below_floor == 0); /* everyone is payable; only slots bind */ + + const char *addrs[N]; + for (int i = 0; i < N; ++i) addrs[i] = c[i].payout_address; + + /* How many the TIGHT coinbase really fits, and what a generous ceiling + * would have claimed. */ + size_t right = coinbase_expected_payout_slots(TIGHT, NULL, addrs, N); + size_t wrong = coinbase_expected_payout_slots(3000, NULL, addrs, N); + CHECK(wrong > right * 2); + + /* Build under both sizings against the SAME tight coinbase, and ask the + * transaction which claims got an output. */ + int big_paid[2]; + size_t sizings[2] = { wrong, right }; + for (int v = 0; v < 2; ++v) { + size_t order[N]; + CHECK(pplns_order_claims(c, N, sizings[v], by_claim, 546, order) == 0); + coinbase_payee_t payees[N]; + for (int i = 0; i < N; ++i) payees[i] = by_claim[order[i]]; + + coinbase_parts_t parts; + coinbase_window_result_t res; + char berr[256] = {0}; + CHECK(coinbase_build_window(800000, 5000000000LL, payees, N, NULL, 0, + NULL, NULL, 4, 8, TIGHT, 546, + &parts, &res, berr, sizeof berr) == 0); + /* Claim 0 is the largest in the window. Where did it land, and was + * that position paid? */ + big_paid[v] = 0; + for (int i = 0; i < N; ++i) { + if (order[i] == 0) { big_paid[v] = res.paid_payee[i]; break; } + } + coinbase_parts_free(&parts); + } + + /* Oversized: the queue took every slot the block had, and the biggest + * miner in the window was paid nothing. */ + CHECK(big_paid[0] == 0); + /* Sized from the ceiling that actually applies: it is paid. */ + CHECK(big_paid[1] == 1); + printf("ok: oversizing the slot estimate starves the largest claims\n"); +} + +/* A reserved slot must not go to a claim the floor is about to drop. + * + * The slot would pay nobody -- the builder drops a sub-floor claim whatever + * position it sits in -- and it is taken from a miner who could have used it. + * That matters over time rather than per block: a miner permanently below the + * floor is skipped by every block, so its owed_fraction only ever GROWS, while + * a byte-capped miner is paid periodically and resets. Run long enough and the + * miners who can never be paid sit at the top of the queue for ever, and the + * rotation stops reaching the miners it exists for + * (LayerTwo-Labs/simplepool#76). + * + * The dropped claim still appears in the order at its own size: the + * permutation covers every claim, because the redistribution and the payout + * queue both need the ones that were skipped. */ +static void test_a_reserved_slot_skips_a_claim_the_floor_will_drop(void) { + enum { N = 20, SLOTS = 8 }; /* 8/4 = two reserved slots */ + pplns_claim_t c[N]; + coinbase_payee_t amounts[N]; + for (int i = 0; i < N; ++i) { + c[i].payout_address = A; c[i].worker_id = i + 1; + c[i].difficulty = 1000.0 / (i + 1); + c[i].owed_fraction = 0.0; + amounts[i].address = A; + amounts[i].sats = 100000; /* comfortably payable */ + } + /* Two miners are waiting. The one owed MORE is worth 100 sats -- under the + * 546-sat dust floor, so no block can pay it. The other is payable. */ + c[N - 1].owed_fraction = 0.900; amounts[N - 1].sats = 100; + c[N - 2].owed_fraction = 0.004; amounts[N - 2].sats = 100000; + + size_t order[N]; + + /* Without the amounts, the sub-floor miner takes the first reserved slot + * and the block pays it nothing -- the behaviour being fixed. */ + CHECK(pplns_order_claims(c, N, SLOTS, NULL, 0, order) == 0); + CHECK(order[0] == N - 1); + + /* With them, it is passed over and the payable waiting miner is promoted + * instead. `continue`, not `break`: one unpayable claim at the head of the + * queue must not close the reservation for everyone behind it. */ + CHECK(pplns_order_claims(c, N, SLOTS, amounts, 546, order) == 0); + CHECK(order[0] == N - 2); + /* Then the largest claims, as usual. */ + CHECK(order[1] == 0); + CHECK(order[2] == 1); + + /* And the skipped claim is still in the order, at its own size: it is the + * smallest, so it is last. */ + CHECK(order[N - 1] == N - 1); + + /* A permutation, still: every claim exactly once. */ + int seen[N] = {0}; + for (int i = 0; i < N; ++i) { CHECK(order[i] < N); seen[order[i]]++; } + for (int i = 0; i < N; ++i) CHECK(seen[i] == 1); + + /* The floor is clamped up to dust the same way the builder clamps it, so + * asking for floor 0 does not resurrect a 100-sat claim. */ + CHECK(pplns_order_claims(c, N, SLOTS, amounts, 0, order) == 0); + CHECK(order[0] == N - 2); + printf("ok: a reserved slot skips a claim the floor will drop\n"); +} + +/* Every claim payable and somebody waiting: the reservation behaves exactly as + * it did before it learned about the floor. The amounts are meant to EXCLUDE, + * never to reorder. */ +static void test_amounts_change_nothing_when_everyone_is_payable(void) { + enum { N = 20, SLOTS = 4 }; + pplns_claim_t c[N]; + coinbase_payee_t amounts[N]; + for (int i = 0; i < N; ++i) { + c[i].payout_address = A; c[i].worker_id = i + 1; + c[i].difficulty = 1000.0 / (i + 1); + c[i].owed_fraction = 0.0; + amounts[i].address = A; amounts[i].sats = 100000; + } + c[N - 1].owed_fraction = 0.004; + + size_t with_[N], without[N]; + CHECK(pplns_order_claims(c, N, SLOTS, NULL, 0, without) == 0); + CHECK(pplns_order_claims(c, N, SLOTS, amounts, 546, with_) == 0); + for (int i = 0; i < N; ++i) CHECK(with_[i] == without[i]); + printf("ok: the amounts exclude, they do not reorder\n"); +} + /* Reserved slots are a minority of the coinbase, always. The biggest claims * are also the ones whose omission wastes the most block, so a rotation that * could take every slot would be worse than the problem it solves. */ @@ -397,7 +615,7 @@ static void test_the_reservation_never_takes_every_slot(void) { } size_t order[N]; for (size_t slots = 1; slots <= N; ++slots) { - CHECK(pplns_order_claims(c, N, slots, order) == 0); + CHECK(pplns_order_claims(c, N, slots, NULL, 0, order) == 0); /* Every claim appears exactly once, whatever the reservation did. */ int seen[N] = {0}; for (size_t i = 0; i < N; ++i) { CHECK(order[i] < N); seen[order[i]]++; } @@ -422,7 +640,7 @@ static void test_being_paid_early_does_not_win_a_reserved_slot(void) { c[i].owed_fraction = -0.5; /* everyone has been paid early */ } size_t order[N]; - CHECK(pplns_order_claims(c, N, 4, order) == 0); + CHECK(pplns_order_claims(c, N, 4, NULL, 0, order) == 0); /* Nobody is owed, so nothing is reserved and it is pure largest-first. */ for (size_t i = 0; i < N; ++i) CHECK(order[i] == i); printf("ok: a negative balance does not win a reserved slot\n"); @@ -495,10 +713,14 @@ int main(void) { test_the_fee_matches_what_the_builder_will_expect(); test_the_floor_and_dust_boundaries_are_exact(); test_the_builder_accepts_what_the_splitter_produces(); + test_a_window_nobody_clears_is_predicted_and_refused(); test_a_window_total_that_is_too_small_is_refused(); test_the_degenerate_inputs_are_refused(); test_with_nothing_owed_the_order_is_largest_first(); test_a_long_waiting_small_miner_reaches_a_slot(); + test_oversizing_the_slot_estimate_starves_the_largest_claims(); + test_a_reserved_slot_skips_a_claim_the_floor_will_drop(); + test_amounts_change_nothing_when_everyone_is_payable(); test_the_reservation_never_takes_every_slot(); test_being_paid_early_does_not_win_a_reserved_slot(); test_conservation_holds_for_random_windows(); diff --git a/tests/test_pplns_coinbase_regtest.sh b/tests/test_pplns_coinbase_regtest.sh index f93432f..bdac67d 100755 --- a/tests/test_pplns_coinbase_regtest.sh +++ b/tests/test_pplns_coinbase_regtest.sh @@ -26,10 +26,10 @@ # 4. NOTHING is owed off-chain, ever. pps_credits must be empty: this mode # writes no ledger row at all, so a row of any size means some other # rail's code path ran. -# 5. the policy is stated in the log. A claim below the payout floor is -# forfeited to the operator and never settled, which is a trap unless -# the operator can see it — so the disclosure lines are asserted here -# exactly like the money is. +# 5. the policy is stated in the log. A claim below the payout floor earns +# that miner nothing from that block — its value goes to the miners the +# coinbase could pay — which is a trap unless the operator can see it, so +# the disclosure lines are asserted here exactly like the money is. # 6. a MIXED window really does redistribute, on chain. Claims of # 100 : 10 : 1 with a floor between the last two: the first two are paid # in the coinbase, the third gets no output, and its satoshis turn up @@ -49,8 +49,16 @@ # An earlier version of this file had a stage that squeezed the byte budget # and printed how much had carried. It printed 0 every time and passed # regardless, which is worse than no stage at all. This one asserts the -# amounts: 1 share in 111 of the payable reward, forfeited, and the operator -# holding strictly more than its fee. +# amounts: 1 share in 111 of the payable reward moving to the other two +# miners, and the operator holding its fee to the satoshi and nothing more. +# +# What it CANNOT reach: a window whose dropped claim is not the LAST entry. +# Order is largest-first until somebody is owed a turn, so a fresh pool always +# drops the tail, and any reading of the paid set as "the first N payees" is +# right by coincidence here. The non-tail case — a reserved small claim placed +# FIRST and then dropped by the floor — is exercised in tests/test_stratum.c +# (test_the_queue_credits_the_miners_the_block_actually_skipped), which is +# where that bug was caught. # # Env: # REGTEST_DIR data dir, WIPED each run (default: /.regtest-cbwin) @@ -323,10 +331,12 @@ print(f" miner {paid[miner]} sats, operator {paid.get(op, 0)} sats") PY stage "assert NOTHING is owed off-chain" -# The payment was the block, so there is no ledger at all in this mode: not +# The payment was the block, so there is no BALANCE ledger in this mode: not # for the miners the coinbase paid, and not for the ones it could not. A claim -# below the payout floor is forfeited to the operator outright — it is income, -# not a debt, and nothing records it. +# below the payout floor goes to the other miners in the same window, and the +# skipped worker gets a row in the payout queue — a memory of whose turn is +# next, against which the pool holds no money. pps_credits is the ledger that +# must stay empty. # # So this is unconditional, which is what makes it worth asserting. Any row # here means some other rail's crediting path ran against a pplns-coinbase diff --git a/tests/test_store.c b/tests/test_store.c index 7acc28a..8dad1d7 100644 --- a/tests/test_store.c +++ b/tests/test_store.c @@ -18,7 +18,7 @@ * no stated reason, which is how a 17th test spent its first run looking like a * store bug. The assert turns the next overrun into an immediate, named * failure; the count is deliberately well clear of the current call sites. */ -#define MAX_TEST_DBS 32 +#define MAX_TEST_DBS 48 static char g_db_paths[MAX_TEST_DBS][256]; static int g_db_count = 0; @@ -1590,10 +1590,68 @@ static void test_fraction_deltas_must_sum_to_zero(void) { assert(store_stage_block_fractions(s, "bb", bad, 2, err, sizeof err) < 0); assert(strstr(err, "sum to") != NULL); + /* A delta with no worker behind it cannot be staged — there is no row to + * hold it. It must not be silently dropped from a set that balances only + * WITH it: what reaches the table would then not cancel, which is the + * exact state the check above exists to make impossible, arrived at by + * passing the check rather than failing it. + * + * pplns_claim_t documents worker_id 0 as "unknown", so this is a shape the + * callers can produce rather than a hypothetical. */ + err[0] = '\0'; + store_fraction_delta_t orphan[] = { {0, 0.25}, {2, -0.25} }; + assert(store_stage_block_fractions(s, "cc", orphan, 2, err, sizeof err) < 0); + assert(strstr(err, "sum to") != NULL); + sqlite3 *db = NULL; + assert(sqlite3_open(path, &db) == SQLITE_OK); + assert(scalar_i64(db, "SELECT COUNT(*) FROM pplns_pending_fractions " + "WHERE block_hash='cc'") == 0); + + /* A set that is entirely worker-less writes nothing and is not an error: + * there is no rotation to record, and nothing about the ledger changed. */ + store_fraction_delta_t none[] = { {0, 0.0} }; + assert(store_stage_block_fractions(s, "dd", none, 1, err, sizeof err) == 0); + assert(scalar_i64(db, "SELECT COUNT(*) FROM pplns_pending_fractions " + "WHERE block_hash='dd'") == 0); + + sqlite3_close(db); store_close(s); printf(" ok test_fraction_deltas_must_sum_to_zero\n"); } +/* With nothing staged, settling must not open a write transaction. + * + * This runs on every reconcile pass in every mode, including the four that can + * never stage a row, and BEGIN IMMEDIATE takes the database's write lock and + * txn_mu with it — stalling the commit thread's share batch to settle a table + * that is empty and always will be. + * + * Asserted by holding the write lock from ANOTHER connection. A settle that + * needs a transaction of its own cannot get one and fails after busy_timeout; + * one that checks first sails past, because it only ever read. */ +static void test_settling_nothing_takes_no_write_lock(void) { + const char *path = fresh_db_path(); + store_cfg_t cfg = {0}; + snprintf(cfg.path, sizeof(cfg.path), "%s", path); + store_t *s = NULL; + assert(store_open(&cfg, &s) == 0); + char err[256] = {0}; + + sqlite3 *writer = NULL; + assert(sqlite3_open(path, &writer) == SQLITE_OK); + assert(sqlite3_exec(writer, "BEGIN IMMEDIATE", NULL, NULL, NULL) == SQLITE_OK); + + int applied = -1, discarded = -1; + assert(store_settle_block_fractions(s, &applied, &discarded, + err, sizeof err) == 0); + assert(applied == 0 && discarded == 0); + + assert(sqlite3_exec(writer, "ROLLBACK", NULL, NULL, NULL) == SQLITE_OK); + sqlite3_close(writer); + store_close(s); + printf(" ok test_settling_nothing_takes_no_write_lock\n"); +} + /* Staged rows do nothing until the block they came from is CONFIRMED — and * are thrown away if it is orphaned. A block that never stood paid nobody and * rotated nobody. */ @@ -1924,6 +1982,7 @@ int main(void) { test_the_payout_floor_is_published_for_the_dashboard(); test_the_window_reads_past_the_first_batch(); test_fraction_deltas_must_sum_to_zero(); + test_settling_nothing_takes_no_write_lock(); test_only_a_confirmed_block_moves_the_queue(); test_two_confirmed_blocks_both_count(); test_concurrent_writers_do_not_lose_each_other(); diff --git a/tests/test_stratum.c b/tests/test_stratum.c index a4805fb..88a0fb4 100644 --- a/tests/test_stratum.c +++ b/tests/test_stratum.c @@ -1,5 +1,6 @@ #include "../src/stratum.h" #include "../src/share.h" +#include "../src/store.h" /* store_fraction_delta_t, for the payout-queue observer */ #include "../src/cjson/cJSON.h" #include @@ -41,6 +42,11 @@ typedef struct { int found_calls; int last_accepted; char last_submit_error[128]; + /* pplns-coinbase payout queue: the deltas the last found block staged. */ + int frac_calls; + size_t frac_n; + int64_t frac_worker[8]; + double frac_delta[8]; } obs_t; /* The callbacks run on whichever thread handled the share, and @@ -95,6 +101,24 @@ static void on_block_found(void *ctx, const char *w, const char *addr, submit_error ? submit_error : ""); } +/* The payout-queue deltas a found block staged. Recorded rather than written, + * so a test can assert who the block decided it had skipped. */ +static void on_window_fractions(void *ctx, const char *block_hash, + const struct store_fraction_delta *d, size_t n) { + (void)block_hash; + obs_t *o = ctx; + if (!o) return; + pthread_mutex_lock(&obs_mu); + o->frac_calls++; + o->frac_n = n > 8 ? 8 : n; + for (size_t i = 0; i < o->frac_n; ++i) { + /* store_fraction_delta_t is {int64_t worker_id; double delta;} */ + o->frac_worker[i] = ((const store_fraction_delta_t *)d)[i].worker_id; + o->frac_delta[i] = ((const store_fraction_delta_t *)d)[i].delta; + } + pthread_mutex_unlock(&obs_mu); +} + /* Helper: parse the first line of an output buffer. Mutates buf (NUL terminator). */ static cJSON *parse_first_line(char *buf) { char *nl = strchr(buf, '\n'); @@ -2914,6 +2938,184 @@ static void test_a_windowless_job_pays_the_finder(void) { stratum_conn_free_for_test(c); stratum_server_free(s);} +/* The payout queue must credit the miners the block ACTUALLY skipped. + * + * This is the only consumer of the builder's paid set, and it is the one place + * where getting it wrong is not a wrong number but a wrong person. A miner the + * coinbase skipped is owed a slot and must come out POSITIVE; a miner it paid + * has been served and must come out zero or negative. Invert that and the + * queue does the opposite of its job: the skipped miner goes to the back and + * is skipped again, for ever, while the miner that was paid is promoted into + * the reserved slots ahead of it. + * + * The case is the likeliest one there is. pplns_order_claims() deliberately + * places a reserved small claim FIRST so it survives the byte budget, and a + * small claim is exactly what the payout floor drops — so the dropped payee is + * at index 0, and any reading of "the first paid_count payees were paid" is + * wrong from the very first entry (LayerTwo-Labs/simplepool#76). + */ +static void test_the_queue_credits_the_miners_the_block_actually_skipped(void) { + obs_t obs = {0}; + stratum_cfg_t cfg = { .bind_port = 0, .max_conns = 2, .initial_diff = 1e12, + .coinbase_pays_window = 1, + .payout_floor_sats = 100000000LL, /* 1 BTC */ + .ctx = &obs, .on_share = on_share, + .on_reject = on_reject, .on_block = on_block, + .on_window_fractions = on_window_fractions }; + snprintf(cfg.bind_addr, sizeof cfg.bind_addr, "127.0.0.1"); + stratum_server_t *s = NULL; + stratum_server_start(&cfg, &s); + CHECK(s != NULL); if (!s) return; + + stratum_conn_t *c = stratum_conn_new_for_test(s); + handshake(s, c); + + uint8_t net[32]; memset(net, 0xff, 32); /* every hash is a block */ + stratum_job_t *job = make_test_job("JQ", net); + /* Worker 101 is under the 1-BTC floor and will be dropped. 102 and 103 + * are not. They sum to make_test_job's 50-BTC value exactly. */ + const coinbase_payee_t win[] = { + { TEST_ADDR, 50000000LL }, /* worker 101 — dropped by the floor */ + { TEST_ADDR, 3000000000LL }, /* worker 102 — paid */ + { TEST_ADDR2, 1950000000LL }, /* worker 103 — paid */ + }; + const int64_t ids[] = { 101, 102, 103 }; + CHECK(stratum_job_set_window(job, win, ids, 3) == 0); + stratum_server_set_job(s, job, 1); + + char *out = NULL; size_t olen = 0; + CHECK(stratum_handle_message(s, c, + "{\"id\":9,\"method\":\"mining.submit\"," + "\"params\":[\"w\",\"JQ\",\"deadbeefcafebabe\",\"60000000\",\"00000001\"]}", + &out, &olen) == 0); + free(out); + CHECK(obs.blocks == 1); + + /* The block staged a rotation, and it named all three workers. */ + CHECK(obs.frac_calls == 1); + CHECK(obs.frac_n == 3); + + double d101 = 0, d102 = 0, d103 = 0; + int seen = 0; + for (size_t i = 0; i < obs.frac_n; ++i) { + if (obs.frac_worker[i] == 101) { d101 = obs.frac_delta[i]; seen |= 1; } + if (obs.frac_worker[i] == 102) { d102 = obs.frac_delta[i]; seen |= 2; } + if (obs.frac_worker[i] == 103) { d103 = obs.frac_delta[i]; seen |= 4; } + } + CHECK(seen == 7); + + /* 101 was skipped: it is owed, so its delta is positive. Under the + * prefix reading this came out NEGATIVE — the skipped miner was recorded + * as having been paid early, which sends it to the back of the queue. */ + CHECK(d101 > 0.0); + /* 102 and 103 were both paid, out of 101's share as well as their own, + * so neither is owed anything. Under the prefix reading 103 came out + * strongly positive despite having received an output. */ + CHECK(d102 <= 0.0); + CHECK(d103 <= 0.0); + /* The set still cancels: redistribution moves value between miners and + * never in or out. store_stage_block_fractions() refuses one that does + * not, so a set that fails here would be dropped on the floor at runtime. */ + CHECK(fabs(d101 + d102 + d103) < 1e-9); + /* And 101's claim is what moved: it was entitled to 1% of the block. */ + CHECK(fabs(d101 - 0.01) < 1e-6); + + stratum_conn_free_for_test(c); + stratum_server_free(s); + printf("ok: the payout queue credits the miner the block actually skipped\n"); +} + +/* A window nothing was dropped from stages nothing. + * + * The deltas are "who did this block treat differently from their claim", so a + * block that paid everyone exactly their share has no rotation to record. Not + * a nicety: staging a row per worker per block on a healthy pool would grow + * the queue table without ever changing anybody's standing. */ +static void test_a_block_that_pays_everyone_stages_no_rotation(void) { + obs_t obs = {0}; + stratum_cfg_t cfg = { .bind_port = 0, .max_conns = 2, .initial_diff = 1e12, + .coinbase_pays_window = 1, + .ctx = &obs, .on_share = on_share, + .on_reject = on_reject, .on_block = on_block, + .on_window_fractions = on_window_fractions }; + snprintf(cfg.bind_addr, sizeof cfg.bind_addr, "127.0.0.1"); + stratum_server_t *s = NULL; + stratum_server_start(&cfg, &s); + CHECK(s != NULL); if (!s) return; + + stratum_conn_t *c = stratum_conn_new_for_test(s); + handshake(s, c); + + uint8_t net[32]; memset(net, 0xff, 32); + stratum_job_t *job = make_test_job("JQ2", net); + const coinbase_payee_t win[] = { + { TEST_ADDR, 3000000000LL }, { TEST_ADDR2, 2000000000LL }, + }; + const int64_t ids[] = { 201, 202 }; + CHECK(stratum_job_set_window(job, win, ids, 2) == 0); + stratum_server_set_job(s, job, 1); + + char *out = NULL; size_t olen = 0; + CHECK(stratum_handle_message(s, c, + "{\"id\":9,\"method\":\"mining.submit\"," + "\"params\":[\"w\",\"JQ2\",\"deadbeefcafebabe\",\"60000000\",\"00000001\"]}", + &out, &olen) == 0); + free(out); + CHECK(obs.blocks == 1); + CHECK(obs.frac_calls == 0); + + stratum_conn_free_for_test(c); + stratum_server_free(s); + printf("ok: a block that pays the whole window stages no rotation\n"); +} + +/* A candidate the node REFUSED rotates nobody. + * + * Its coinbase paid no one, so recording that it skipped somebody would move a + * miner down the queue for a payment that never happened — the same reason the + * staged rows are discarded when a block is orphaned. */ +static void test_a_rejected_candidate_stages_no_rotation(void) { + obs_t obs = {0}; + obs.submit_rejects = 1; + stratum_cfg_t cfg = { .bind_port = 0, .max_conns = 2, .initial_diff = 1e12, + .coinbase_pays_window = 1, + .payout_floor_sats = 100000000LL, + .ctx = &obs, .on_share = on_share, + .on_reject = on_reject, .on_block = on_block, + .on_window_fractions = on_window_fractions }; + snprintf(cfg.bind_addr, sizeof cfg.bind_addr, "127.0.0.1"); + stratum_server_t *s = NULL; + stratum_server_start(&cfg, &s); + CHECK(s != NULL); if (!s) return; + + stratum_conn_t *c = stratum_conn_new_for_test(s); + handshake(s, c); + + uint8_t net[32]; memset(net, 0xff, 32); + stratum_job_t *job = make_test_job("JQ3", net); + const coinbase_payee_t win[] = { + { TEST_ADDR, 50000000LL }, + { TEST_ADDR, 3000000000LL }, + { TEST_ADDR2, 1950000000LL }, + }; + const int64_t ids[] = { 301, 302, 303 }; + CHECK(stratum_job_set_window(job, win, ids, 3) == 0); + stratum_server_set_job(s, job, 1); + + char *out = NULL; size_t olen = 0; + stratum_handle_message(s, c, + "{\"id\":9,\"method\":\"mining.submit\"," + "\"params\":[\"w\",\"JQ3\",\"deadbeefcafebabe\",\"60000000\",\"00000001\"]}", + &out, &olen); + free(out); + CHECK(obs.submits == 1); + CHECK(obs.frac_calls == 0); + + stratum_conn_free_for_test(c); + stratum_server_free(s); + printf("ok: a candidate the node refused rotates nobody\n"); +} + /* The share-dedupe index under churn. The ring holds SHARE_DEDUPE_RING keys * and the index must answer for exactly those: a key inside the window is a * duplicate, a key that fell out of it is not, and after any amount of @@ -3048,6 +3250,9 @@ int main(void) { test_a_listener_ceiling_changes_how_many_the_coinbase_pays(); test_a_listener_without_a_ceiling_uses_the_server_wide_one(); test_pplns_coinbase_pays_every_miner_in_the_window(); + test_the_queue_credits_the_miners_the_block_actually_skipped(); + test_a_block_that_pays_everyone_stages_no_rotation(); + test_a_rejected_candidate_stages_no_rotation(); test_pplns_btc_takes_a_bitcoin_username(); test_pplns_thunder_takes_a_thunder_username(); test_pplns_is_never_gated(); From dde5f1d27795557f6a12d3fbb57e07b44edca5fd Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 10 Sep 2026 20:06:17 +0200 Subject: [PATCH 31/36] pplns-coinbase: the first job after a restart carried no window The initial job was published windowless on the premise that a process which has just started has no shares to pay and no network difficulty to size a window with. Neither holds on a restart: the shares table persists, and refresh_pps_rate() had already read the difficulty out of the same template a few lines earlier. The tip watcher only rebuilds on a new tip or after its 30-second refresh, so the windowless job stood for up to 30 seconds after every restart, and a block found in that gap paid its finder alone -- the whole window skipped, and nothing staged in the payout queue to say so. main() now attaches the window to the initial job the way the watcher does for every other one. A fresh pool still gets the bootstrap job, but from attach_pplns_window() itself, which already logs that case. A first template that cannot carry a window is not published, on the watcher's rule, and last_built_ms is cleared so the watcher's first poll rebuilds rather than waiting out the refresh. The e2e's mixed-window stage is a restart with a full shares table, which is exactly the shape that was wrong. It now asserts the floor warning appears before the watcher builds a single job -- a "new job:" line ahead of it means the initial job went out without a window -- and the bootstrap stage keys on attach_pplns_window()'s own "no shares yet" line, which is deterministic for the same reason the old one was. --- src/main.c | 51 ++++++++++++++++++---------- tests/test_pplns_coinbase_regtest.sh | 33 ++++++++++++------ 2 files changed, 57 insertions(+), 27 deletions(-) diff --git a/src/main.c b/src/main.c index 73f94ef..107bdc0 100644 --- a/src/main.c +++ b/src/main.c @@ -450,7 +450,8 @@ static int attach_pplns_window(store_t *store, const proxy_config_t *cfg, * operator can act on -- by telling those miners, or by lowering the * floor. Rate-limited to changes in the count, because it is recomputed * on every template and a steady state is not news -- the static needs no - * lock, this runs only on the template-poller thread. + * lock: main() calls this once for the initial job before the + * template-poller thread exists, and only that thread calls it after. * * The clamp mirrors coinbase.c's: below the dust limit there is no floor * to have, so reporting an unclamped one would understate who loses. */ @@ -1557,23 +1558,39 @@ int main(int argc, char **argv) { /* First job of the process: nobody is connected yet, so the flag reaches * no one, but a new tip is what it describes. * - * Under pplns-coinbase this one deliberately carries no window. Network - * difficulty has not been read yet, and a process that has just started - * has no shares to pay anyway — so the window would be empty even if it - * could be sized. conn_render_coinbase refuses to render from a - * windowless job rather than paying nobody, and the tip watcher publishes - * a job with a real window within one poll interval. */ + * Under pplns-coinbase it carries the window like every other job. This + * used to be skipped on the premise that a process which has just started + * has no shares to pay and no difficulty to size a window with. Neither + * holds on a RESTART: the shares table persists, and refresh_pps_rate() + * above has already read the difficulty out of this same template. The + * tip watcher only rebuilds on a new tip or after its 30-second refresh, + * so a windowless first job stood for up to 30 seconds after every + * restart, and a block found in that gap paid its finder alone -- the + * whole window skipped, and nothing staged in the payout queue to say so. + * + * The bootstrap case is unchanged and now lives in one place: a pool with + * no shares yet gets a job with no window from attach_pplns_window(), + * which says so, and conn_render_coinbase() pays the finder from it. A + * template that cannot carry a window is not published, on the same rule + * the tip watcher applies; the watcher rebuilds on its first poll and the + * pool starts serving work from the first template that can. */ if (strcmp(cfg.pool_mode, "pplns-coinbase") == 0) { - /* Said out loud because it is otherwise invisible: this job renders a - * solo-shaped coinbase, and an operator watching the first block of a - * new pool get paid entirely to its finder deserves to know that was - * deliberate rather than the window silently failing. */ - LOG_INFO("pplns-coinbase: the first job of a process carries no " - "window — network difficulty is unread and a fresh pool has " - "no shares — so it pays whoever finds it, as solo would. " - "Every job after the first accepted share carries a window."); - } - stratum_server_set_job(srv, initial_job, 1); + double nd = atomic_load_explicit(&sctx.net_difficulty, + memory_order_relaxed); + if (attach_pplns_window(store, &cfg, nd, tmpl, initial_job) != 0) { + LOG_WARN("pplns-coinbase: the first template cannot carry a " + "window, so no job is published from it; the tip " + "watcher retries on its next poll"); + stratum_job_free(initial_job); + initial_job = NULL; + /* Make the watcher's first poll a rebuild rather than a 30-second + * wait: last_built_ms is what the periodic refresh keys on. */ + pthread_mutex_lock(&sctx.lock); + sctx.last_built_ms = 0; + pthread_mutex_unlock(&sctx.lock); + } + } + if (initial_job) stratum_server_set_job(srv, initial_job, 1); /* A port's promised floor and the chain can disagree, and the floor wins * (see clamp_assigned_difficulty). When it does, every miner on that port diff --git a/tests/test_pplns_coinbase_regtest.sh b/tests/test_pplns_coinbase_regtest.sh index bdac67d..2177899 100755 --- a/tests/test_pplns_coinbase_regtest.sh +++ b/tests/test_pplns_coinbase_regtest.sh @@ -263,11 +263,10 @@ stage "assert the FIRST block took the bootstrap path" # Worth pinning explicitly, because it is the path that used to deadlock: a # pool with no shares has no window, and refusing to render there meant no # coinbase, so no share, so no window, forever. -# Deterministic, unlike the tip-watcher's own empty-window message: on a fast -# chain the first block can be found before the first tip change, so whether -# the watcher ever SEES an empty window is a race. The initial job always -# carries none, and always says so. -grep -q "the first job of a process carries no window" "$POOL_LOG" || { +# Deterministic: the initial job goes through attach_pplns_window() like every +# other, and on a pool with no shares that always finds an empty window and +# always says so -- before any miner connects, so before any tip can change. +grep -q "no shares yet, so no window" "$POOL_LOG" || { echo "FAIL: expected the first job to be announced as windowless" >&2 exit 1; } echo " bootstrap path announced, as it must be on a pool with no shares" @@ -458,11 +457,14 @@ kill -0 "$POOL_PID" 2>/dev/null || { # The pool must SAY the small miner is about to earn nothing, before a block # makes it true. That warning is the operator's only chance to act. # -# Up to 60s, because the FIRST job of a process carries no window -- network -# difficulty is unread until a template arrives -- and the tip watcher only -# rebuilds on a new tip or its 30-second refresh. A 20-second wait looked like -# "the pool never warned" when it simply had not built a second job yet. -for _ in $(seq 1 60); do +# And it must say so from the FIRST job. This is a restart with a full shares +# table -- exactly the shape of a production restart -- and the first job used +# to carry no window on the premise that a fresh process has no shares to pay. +# It stood for up to 30 seconds, the tip watcher's refresh interval, and a +# block found in that gap paid its finder alone. So the warning has to appear +# before the watcher has built a single job: if the first "new job:" line +# precedes it, the initial job went out windowless and the bug is back. +for _ in $(seq 1 20); do grep -q "below the ${MIX_FLOOR}-sat payout floor" "$MIX_LOG" && break sleep 1 done @@ -470,6 +472,17 @@ grep -q "below the ${MIX_FLOOR}-sat payout floor" "$MIX_LOG" || { echo "FAIL: the pool never warned that a miner falls below the floor" >&2 grep -i "floor" "$MIX_LOG" | tail -5 >&2; exit 1; } echo " warned: $(grep -o '[0-9]* of [0-9]* miner(s) in the window are below' "$MIX_LOG" | tail -1)" +FIRST_WARN=$(grep -n "below the ${MIX_FLOOR}-sat payout floor" "$MIX_LOG" | head -1 | cut -d: -f1) +# No rebuild yet is the expected case, and under pipefail an empty grep is +# exit 1, so it must not take the script down with it. +FIRST_JOB=$( (grep -n "new job: height=" "$MIX_LOG" || true) | head -1 | cut -d: -f1) +if [ -n "$FIRST_JOB" ] && [ "$FIRST_JOB" -lt "$FIRST_WARN" ]; then + echo "FAIL: the tip watcher built a job (log line $FIRST_JOB) before the" >&2 + echo " initial job's window was measured (line $FIRST_WARN) -- the" >&2 + echo " first job after a restart went out without a window" >&2 + exit 1 +fi +echo " the initial job carried the window (warned at line $FIRST_WARN, first rebuild at line ${FIRST_JOB:-none})" MIX_BEFORE=$(cli getblockcount) node "$ROOT/scripts/regtest/cpuminer.js" --port "$POOL_PORT" --user "$MINER_ADDR" --timeout 180 From 02c6a290c45f0004234a5bbe25ae5b88831887e8 Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 10 Sep 2026 20:06:17 +0200 Subject: [PATCH 32/36] store: read the result of COMMIT txn_commit() ran COMMIT, dropped the mutex and returned void. A COMMIT that fails and that sqlite does not roll back on its own -- BUSY is the documented case -- left the connection inside the transaction after the caller had been told its write succeeded. From then on every BEGIN on the shared connection failed with "cannot start a transaction within a transaction"; commit_batch() gave up after three attempts per batch and logged each as LOST, so one unread return code became every share dropped until restart. Rare under WAL with BEGIN IMMEDIATE; the blast radius is the whole pool. It now returns 0 only when the transaction is durable, and otherwise rolls back, counts a pg_error and returns -1. The three writers propagate it: staging reports that nothing was staged (the caller already logs that the block's rotation is lost, which is now true rather than optimistic), settlement leaves the staged rows for the next pass, and the distributor treats it as a failed distribution -- the latch was inside the transaction, so the block is retried and nothing is credited twice. Pinned in the walk harness, which already includes store.c as source: sqlite3_exec is redirected alongside sqlite3_step, and one test refuses a COMMIT without executing it. The caller must be told, the connection must be back in autocommit, the share batches after it must land, and the refused staging must have written nothing. Against the old helper it fails with "COMMIT failed and staging returned 2 (success) anyway". --- src/store.c | 62 ++++++++++++++++++++--- tests/test_store_walk.c | 109 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 163 insertions(+), 8 deletions(-) diff --git a/src/store.c b/src/store.c index bbf81b8..ccb7cd0 100644 --- a/src/store.c +++ b/src/store.c @@ -1323,9 +1323,34 @@ static int txn_begin(store_t *s) { } return 0; } -static void txn_commit(store_t *s) { - sqlite3_exec(s->db, "COMMIT", NULL, NULL, NULL); +/* Returns 0 when the transaction is durable, -1 when it is not -- in which + * case it has been rolled back and the connection is back in autocommit. + * + * The result of COMMIT has to be read. It used to be ignored, and a failed + * COMMIT that sqlite does not roll back on its own (BUSY is the documented + * case) left the connection INSIDE the transaction after the caller had been + * told its write succeeded. From then on every BEGIN on this connection fails + * with "cannot start a transaction within a transaction": commit_batch() + * retries three times per batch and then logs the batch as LOST, so one + * unread rc turned into every share being dropped until restart. Rare under + * WAL with BEGIN IMMEDIATE, and the blast radius is the whole pool. + * + * The ROLLBACK is issued whether or not sqlite already did it -- on a + * connection that is already in autocommit it fails harmlessly with "no + * transaction is active", and sqlite3_get_autocommit() is the check the + * tests use to prove the connection came out clean either way. */ +static int txn_commit(store_t *s) { + char *err = NULL; + if (sqlite3_exec(s->db, "COMMIT", NULL, NULL, &err) == SQLITE_OK) { + pthread_mutex_unlock(&s->txn_mu); + return 0; + } + LOG_WARN("store: COMMIT failed: %s -- rolling back", err ? err : "?"); + sqlite3_free(err); + sqlite3_exec(s->db, "ROLLBACK", NULL, NULL, NULL); pthread_mutex_unlock(&s->txn_mu); + atomic_fetch_add(&s->pg_errors, 1); + return -1; } static void txn_rollback(store_t *s) { sqlite3_exec(s->db, "ROLLBACK", NULL, NULL, NULL); @@ -1472,7 +1497,17 @@ int store_pplns_distribute(store_t *s, int maturity_confs, int fee_bps, sqlite3_finalize(mark); if (ok) { - txn_commit(s); + /* A commit that did not land is a distribution that did not + * happen: the latch is rolled back with it, so the next pass + * retries the block. Nothing was credited, so nothing is owed + * twice. */ + if (txn_commit(s) != 0) { + if (errbuf && errlen) + snprintf(errbuf, errlen, "distribute %.16s: commit failed", + hbuf); + rc_out = -1; + break; + } blocks++; workers += credited_here; LOG_INFO("pplns: block %.16s… distributed %lld sats of %lld across " @@ -1757,7 +1792,14 @@ int store_stage_block_fractions(store_t *s, const char *block_hash, atomic_fetch_add(&s->pg_errors, 1); return -2; } - txn_commit(s); + /* Reported as a failure, not as `wrote`: the caller logs that the + * rotation for this block is lost, which is true, and which is far better + * than believing rows are staged that are not. */ + if (txn_commit(s) != 0) { + if (errbuf && errlen) + snprintf(errbuf, errlen, "commit failed; nothing was staged"); + return -2; + } return wrote; } @@ -1874,7 +1916,14 @@ int store_settle_block_fractions(store_t *s, int *out_applied, atomic_fetch_add(&s->pg_errors, 1); return -2; } - txn_commit(s); + /* The staged rows are still there, so the next pass settles them; the + * caller's WARN says exactly that. Reporting applied=N here would say the + * rotation happened when it did not. */ + if (txn_commit(s) != 0) { + if (errbuf && errlen) + snprintf(errbuf, errlen, "commit failed; the staged rows stay"); + return -2; + } if (out_applied) *out_applied = applied; if (out_discarded) *out_discarded = discarded; return 0; @@ -1887,8 +1936,7 @@ int store_begin_txn_for_test(store_t *s) { int store_end_txn_for_test(store_t *s) { if (!s || !s->db) return -1; - txn_commit(s); - return 0; + return txn_commit(s); } int store_rollback_txn_for_test(store_t *s) { diff --git a/tests/test_store_walk.c b/tests/test_store_walk.c index be6d26b..986bc7a 100644 --- a/tests/test_store_walk.c +++ b/tests/test_store_walk.c @@ -22,13 +22,37 @@ #include static int tsw_step(sqlite3_stmt *st); +static int tsw_exec(sqlite3 *db, const char *sql, + int (*cb)(void *, int, char **, char **), void *arg, + char **errmsg); static void maybe_delete_oldest(sqlite3_stmt *st); static void maybe_insert_behind_the_walk(sqlite3_stmt *st); -/* Redirect every sqlite3_step() inside store.c to the wrapper below. */ +/* Redirect every sqlite3_step() and sqlite3_exec() inside store.c to the + * wrappers below. exec is where BEGIN/COMMIT/ROLLBACK go, so it is the seam + * for failing a COMMIT. */ #define sqlite3_step tsw_step +#define sqlite3_exec tsw_exec #include "../src/store.c" #undef sqlite3_step +#undef sqlite3_exec + +/* When set, the next COMMIT is refused with SQLITE_BUSY and NOT executed, so + * the transaction genuinely stays open on the connection -- the case sqlite + * documents as "might not be rolled back automatically", and the one that + * used to leave every later BEGIN failing. Cleared once it fires. */ +static int g_fail_commit_once = 0; + +static int tsw_exec(sqlite3 *db, const char *sql, + int (*cb)(void *, int, char **, char **), void *arg, + char **errmsg) { + if (g_fail_commit_once && sql && strcmp(sql, "COMMIT") == 0) { + g_fail_commit_once = 0; + if (errmsg) *errmsg = sqlite3_mprintf("database is locked (injected)"); + return SQLITE_BUSY; + } + return sqlite3_exec(db, sql, cb, arg, errmsg); +} /* -1 disables injection. Otherwise: let the boundary query succeed this many * times, then fail it. 0 fails its very first step. */ @@ -357,8 +381,91 @@ static void test_shares_landing_mid_walk_are_not_swept_in(void) { "(%lld rows in the table, window still 500)\n", (long long)rows); } +/* A COMMIT that fails must be reported as a failure, and must leave the + * connection OUT of the transaction. + * + * txn_commit() used to ignore the rc. A failed COMMIT that sqlite does not + * roll back itself left the connection inside the transaction after the + * caller had returned success; every BEGIN after that failed with "cannot + * start a transaction within a transaction", commit_batch() gave up after + * three, and every share batch from then on was logged as LOST until restart. + * The write that "succeeded" was never there either. + * + * Injected on COMMIT and nowhere else: the rows are bound and stepped for + * real, so this is exactly the moment the old code told the caller "wrote 2" + * with nothing durable behind it. */ +static void test_a_failed_commit_is_reported_and_leaves_no_open_transaction(void) { + const char *path = fresh_path(); + store_t *s = open_with_shares(path, 8, 1.0); + char err[256] = {0}; + + store_fraction_delta_t d[] = { {1, 0.25}, {2, -0.25} }; + g_fail_commit_once = 1; + int rc = store_stage_block_fractions(s, "blk_commit_fails", d, 2, + err, sizeof err); + assert(g_fail_commit_once == 0 && "the injection did not fire"); + if (rc >= 0) { + fprintf(stderr, " FAIL: COMMIT failed and staging returned %d " + "(success) anyway\n", rc); + assert(rc < 0); + } + assert(err[0] != '\0'); + /* The connection is back in autocommit: nothing is left open for the + * next BEGIN to trip over. */ + if (!sqlite3_get_autocommit(s->db)) { + fprintf(stderr, " FAIL: the connection is still inside the failed " + "transaction\n"); + assert(0); + } + + /* Everything after it works: the share batches the old bug lost... */ + for (int i = 0; i < 40; ++i) { + assert(store_record_share_addr(s, "w_after", "addr_after", + 5000ULL + (uint64_t)i, 1.0, + 0, NULL, 0, 0.0) == 0); + } + assert(store_flush(s) == 0); + /* ...and the staging path itself. */ + assert(store_stage_block_fractions(s, "blk_after", d, 2, + err, sizeof err) == 2); + + sqlite3 *db = NULL; + assert(sqlite3_open(path, &db) == SQLITE_OK); + sqlite3_stmt *c = NULL; + assert(sqlite3_prepare_v2(db, + "SELECT (SELECT COUNT(*) FROM shares sh JOIN workers w ON w.id = sh.worker_id" + " WHERE w.name = 'w_after')," + " (SELECT COUNT(*) FROM pplns_pending_fractions WHERE block_hash='blk_commit_fails')," + " (SELECT COUNT(*) FROM pplns_pending_fractions WHERE block_hash='blk_after')", + -1, &c, NULL) == SQLITE_OK); + assert(sqlite3_step(c) == SQLITE_ROW); + sqlite3_int64 shares_after = sqlite3_column_int64(c, 0); + sqlite3_int64 rows_failed = sqlite3_column_int64(c, 1); + sqlite3_int64 rows_after = sqlite3_column_int64(c, 2); + sqlite3_finalize(c); + sqlite3_close(db); + + if (shares_after != 40) { + fprintf(stderr, " FAIL: %lld of 40 shares survived the batches after " + "the failed COMMIT\n", (long long)shares_after); + assert(0); + } + /* The failed staging wrote nothing -- it said so -- and the later one + * wrote everything. */ + assert(rows_failed == 0); + assert(rows_after == 2); + /* pg_errors counted it, so the dashboard's health check sees it too. */ + assert(atomic_load(&s->pg_errors) >= 1); + + store_close(s); + unlink(path); + printf(" ok test_a_failed_commit_is_reported_and_leaves_no_open_transaction " + "(reason: %s)\n", err); +} + int main(void) { test_the_walk_serves_the_configured_window_when_nothing_fails(); + test_a_failed_commit_is_reported_and_leaves_no_open_transaction(); test_a_walk_that_fails_immediately_does_not_serve_the_whole_table(); test_a_walk_that_fails_after_widening_does_not_serve_a_short_window(); test_a_row_deleted_during_the_walk_still_terminates(); From a4f3627b06d484582b5318b882243c145a598e78 Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 10 Sep 2026 20:06:17 +0200 Subject: [PATCH 33/36] docs: say when the payout queue settles, finish the forfeit sweep, bump to 0.4.0 Three leftovers from review. The queue is applied at ONE confirmation, not the distributor's hundred, because it has to describe the last block before the next one is built and nothing in it is money. The cost -- a block reorged out after that keeps its rotation, one turn out of order, corrected by the next block found -- was nowhere in the docs. README, VERIFY, the HTML, proxy.conf.example and schema.sql now say so beside the sentence that promised an orphan rotates nobody. Three comments still described the rule the redistribution replaced: the pool_meta floor column in schema.sql, the header of the pool_meta floor test in test_store.c, and the header of the dashboard disclosure test -- whose own assertions forbid the page from saying what the header said. VERSION goes to 0.4.0. CHANGELOG.md carries the 0.4.0 section in this PR and RELEASING.md says the bump belongs in the same one, or the binary a v0.4.0 tag builds reports 0.3.0. --- Makefile | 2 +- README.md | 7 +++++++ VERIFY.md | 4 +++- .../test/pplns-coinbase-disclosure.test.js | 16 ++++++++++------ docs/simplepool.html | 3 ++- proxy.conf.example | 4 +++- schema.sql | 19 ++++++++++++++----- tests/test_store.c | 12 +++++++----- 8 files changed, 47 insertions(+), 20 deletions(-) diff --git a/Makefile b/Makefile index 977c85f..df5cdeb 100644 --- a/Makefile +++ b/Makefile @@ -60,7 +60,7 @@ BIN := $(BUILD_DIR)/simplepool # different question: a tree gets patched or moves on past the last `make`, # and from then on its HEAD is not what the running process was built from. # Empty outside a git checkout (release tarball) — reported as "unknown". -VERSION := 0.3.0 +VERSION := 0.4.0 GIT_COMMIT := $(shell git rev-parse HEAD 2>/dev/null) GIT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD 2>/dev/null) GIT_DIRTY := $(shell git status --porcelain --untracked-files=no 2>/dev/null | head -1) diff --git a/README.md b/README.md index 4de8d7d..4ba0f82 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,13 @@ and what a stratum username is: only once it is confirmed, so an orphaned block — which paid nobody — rotates nobody. + "Confirmed" means one block deep, not a hundred: the queue has to reflect + the last block before the next one is built, and money is not at stake. The + cost is that a block reorged out *after* that first confirmation keeps its + rotation — the miners it paid stay at the back of the queue and the ones it + skipped stay at the front — for a payment that never stood. That is one turn + out of order, never a satoshi, and the next block found corrects it. + **If the pool cannot measure the window, it publishes no job at all.** The window is read back over a bounded walk of the shares table; if that walk cannot prove it covered the configured window — an IO error, a lock held too diff --git a/VERIFY.md b/VERIFY.md index 2d4b5cf..6507bb9 100644 --- a/VERIFY.md +++ b/VERIFY.md @@ -551,7 +551,9 @@ homework. `bitcoin-cli getblock 2 | jq '.tx[0].vout'`: - [ ] After a block is found but before it confirms, its rows are in `pplns_pending_fractions` and **not** in `pplns_fractions`. An orphaned block paid nobody and must rotate nobody; the confirmation pass is what - applies them. + applies them. It applies them at ONE confirmation, so a block reorged + out after that has already rotated the queue and is not reversed — a + turn out of order, not money, and the next block corrects it. ### 13.4 · The byte budget diff --git a/dashboard/test/pplns-coinbase-disclosure.test.js b/dashboard/test/pplns-coinbase-disclosure.test.js index 50e7bf3..c07302a 100644 --- a/dashboard/test/pplns-coinbase-disclosure.test.js +++ b/dashboard/test/pplns-coinbase-disclosure.test.js @@ -1,11 +1,15 @@ /* What a pplns-coinbase pool tells the people it costs. * - * This mode forfeits a claim below the payout floor to the operator and never - * settles it. That is defensible as a stated rule and indefensible as a - * discovery, and the whole case for the policy rests on the miner being able - * to see it BEFORE pointing a rig at the pool. The operator's log is the one - * place they cannot look, so these tests treat the disclosure as part of the - * feature rather than as decoration. + * This mode pays a claim below the payout floor nothing from that block: its + * share goes to the other miners in the window (never the operator) and the + * miner is owed a turn in the payout queue, so being small costs frequency + * rather than money. That is defensible as a stated rule and indefensible as + * a discovery, and the whole case for the policy rests on the miner being + * able to see it BEFORE pointing a rig at the pool. The operator's log is the + * one place they cannot look, so these tests treat the disclosure as part of + * the feature rather than as decoration. The header of this file said the + * old rule -- forfeited to the operator -- for a while after the tests below + * started asserting the page must not say that. * * They also pin the mislabels this mode exposed. The dashboard used to answer * "not pps-classic" with the word "solo" in three places, so every pplns pool diff --git a/docs/simplepool.html b/docs/simplepool.html index 27579f3..fb29ed2 100644 --- a/docs/simplepool.html +++ b/docs/simplepool.html @@ -2052,7 +2052,8 @@

    The data model

    pplns-coinbase only. What a FOUND block did to those fractions, held against its hash until the confirmation pass decides. A found block is a candidate: applying it there would rotate a miner down the queue for a payment an orphan never - made + made. Applied at one confirmation and not reversed by a later reorg — that + costs a turn out of order, never a satoshi diff --git a/proxy.conf.example b/proxy.conf.example index 2b463c6..048c3af 100644 --- a/proxy.conf.example +++ b/proxy.conf.example @@ -264,7 +264,9 @@ pool_mode = solo # the reward followed by one paying more, and the second is invalid. Delete the # table and nobody is owed a payment; the pool just forgets whose turn it was. # Rows are staged when a block is found and applied only once it confirms, so -# an orphaned block rotates nobody. +# an orphaned block rotates nobody. Confirmed means one block deep, so a block +# reorged out AFTER that keeps its rotation: one turn out of order, never a +# satoshi, and corrected by the next block found. # # So being small here costs a miner FREQUENCY, not money. Say that on your # pool page — the proxy states the floor at startup, per template, per block, diff --git a/schema.sql b/schema.sql index cc3f6d9..720830c 100644 --- a/schema.sql +++ b/schema.sql @@ -139,13 +139,16 @@ CREATE TABLE IF NOT EXISTS pool_meta ( pool_btc_address TEXT, /* pps-classic only; NULL in solo */ pool_mode TEXT, /* pplns-coinbase only; NULL in every other mode. The least a claim must be - * worth to get a coinbase output at all -- below it a miner is not paid and - * the amount goes to the operator, permanently. + * worth to get a coinbase output at all -- below it a miner is not paid BY + * THAT BLOCK. Its share goes to the other miners in the window, never to + * the operator, and the miner is recorded in pplns_fractions as owed a + * turn, so a later block with room reaches it first. Being small costs + * frequency, not money. * * Published here because the miner it costs reads the dashboard, not the - * operator's log. A forfeit policy nobody can see from outside is not a - * policy, it is a surprise, and the whole case for having one is that it is - * stated up front. */ + * operator's log. A floor nobody can see from outside is not a policy, it + * is a surprise, and the whole case for having one is that it is stated up + * front. */ pplns_payout_floor_sats INTEGER, fee_bps INTEGER, rate_source TEXT, /* 'derived' | 'override' */ @@ -368,6 +371,12 @@ CREATE TABLE IF NOT EXISTS pplns_fractions ( -- received. The confirmation pass applies these when the block is confirmed -- and deletes them when it is orphaned -- the same rule, and the same reason, -- as PPLNS distribution. +-- +-- "Confirmed" here is ONE block deep, not the distributor's hundred: the queue +-- must describe the last block before the next one is built, and nothing here +-- is money. So a block reorged out after that first confirmation keeps its +-- rotation. That is one turn out of order, corrected by the next block found, +-- and never a satoshi. CREATE TABLE IF NOT EXISTS pplns_pending_fractions ( block_hash TEXT NOT NULL, worker_id INTEGER NOT NULL, diff --git a/tests/test_store.c b/tests/test_store.c index 8dad1d7..170448a 100644 --- a/tests/test_store.c +++ b/tests/test_store.c @@ -1452,11 +1452,13 @@ static void test_an_empty_window_returns_nothing_not_an_error(void) { /* The payout floor has to reach the DASHBOARD, not just the operator's log. * - * pplns-coinbase forfeits a claim below the floor to the operator and never - * settles it, and the entire case for that policy is that it is disclosed up - * front. The miner it costs reads the dashboard; the operator's terminal is - * the one place they cannot see. So the floor being in pool_meta is part of - * the policy, not a nicety. + * pplns-coinbase pays a claim below the floor nothing from that block -- its + * share goes to the other miners in the window and the miner is owed a turn + * in the payout queue, so being small costs frequency rather than money -- + * and the entire case for that policy is that it is disclosed up front. The + * miner it costs reads the dashboard; the operator's terminal is the one + * place they cannot see. So the floor being in pool_meta is part of the + * policy, not a nicety. * * NULL in every other mode, distinctly from 0: "this pool has no floor" and * "this pool's floor is zero sats" are different claims, and only the first From ea305a3ae8d4a05ef8d1b99784f0b525ffb833c2 Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 10 Sep 2026 23:04:04 +0200 Subject: [PATCH 34/36] dashboard: say which port a miner should use, and what the floor costs them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pool serves more than one stratum port and nothing miner-facing said why. The identity strip lists them, but a strip has room to name a port, not to explain the choice — and the choice is not cosmetic: a marketplace aggregates a whole fleet behind ONE connection, so the same hashrate that is 12 shares a minute from a home ASIC is ~232 000 submits a second on the port meant for that ASIC. The pool rate-limits the connection, the miner sees rejects, and the order is cancelled for work the pool appears to be refusing. Vardiff cannot repair it after the fact either: at 4x per window it takes eight windows to climb from 1 to 65 536, and the rejects on the way up are what gets the order pulled. So the connect card now lists every published port as a dialable URL with the difficulty behind it and who it is for, followed by why there is more than one. The second half is the disclosure. A port with min_diff KEEPS its floor when the chain is easier — deliberately, since a marketplace measures the wire and cancels an order served under what the port advertised — and the cost of that lands on the miner, who filters locally at the assigned difficulty and throws away blocks the chain would have taken. On a 500 000 port over a chain at 1 200 that is 416 of every 417 blocks solved. health.js has reported this to the operator since the floors landed; the miner paying for it was never told. It is now stated on the card, with the live ratio, and only when the chain is actually under the floor — a floor the network has passed costs nothing and says so, and a pool with no floored port at all gets neither paragraph. promised_min_diff is carried through parseListeners for this. It is the field that separates the two cases: min_diff is the rate-loop bound the network difficulty still clamps, promised_min_diff is the one that is kept. Reading a missing promise as a floor would print a block-loss warning to every miner of a pool losing nothing, so an older proxy's listener JSON reads as no promise. The earnings note branches on mode. "Work is credited by its difficulty" is the right answer to "does the big port pay less per share" in four modes and a promise solo never makes, where nothing is credited between blocks at all. --- CHANGELOG.md | 12 ++ dashboard/lib/stats.js | 9 ++ dashboard/test/connect-ports.test.js | 156 ++++++++++++++++++++++ dashboard/views/partial/about-numbers.ejs | 6 + dashboard/views/partial/connect-ports.ejs | 141 +++++++++++++++++++ 5 files changed, 324 insertions(+) create mode 100644 dashboard/test/connect-ports.test.js create mode 100644 dashboard/views/partial/connect-ports.ejs diff --git a/CHANGELOG.md b/CHANGELOG.md index 394a70d..7bbde6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,18 @@ listener = port=3335 label=rental min_diff=500000 initial_diff=500000 max_coinba - **Pool solvency** counted `blocks_found.reward_sats` as pool revenue in `pplns-coinbase`, where that is what the block paid the *miners* — reporting a healthy margin for a pool that holds nothing. Now skipped, with the reason. +- The connect card now says **which port to point which miner at**. Every + published port is listed as a dialable URL with the difficulty behind it and + who it is for, because a stratum URL says nothing about either and a rented + fleet on the home-miner port is one connection submitting hundreds of + thousands of shares a second — the pool limits it and the marketplace + cancels the order for work the pool appears to be rejecting. +- **What a held floor costs is now disclosed to the miner paying for it.** A + port holding difficulty 500 000 over a chain at 1 200 makes its miners + discard roughly 416 of every 417 blocks they solve, since a miner filters + locally at the difficulty it was assigned. That arithmetic was already in + the operator's *"Stratum ports can hold their difficulty"* health check; + nobody mining on the port ever saw it. ### Testing diff --git a/dashboard/lib/stats.js b/dashboard/lib/stats.js index 7c750e2..1577bf8 100644 --- a/dashboard/lib/stats.js +++ b/dashboard/lib/stats.js @@ -492,6 +492,15 @@ function parseListeners(raw) { label: (typeof l.label === 'string' && l.label) ? l.label : null, min_diff: Number.isFinite(Number(l.min_diff)) ? Number(l.min_diff) : null, initial_diff: Number.isFinite(Number(l.initial_diff)) ? Number(l.initial_diff) : null, + /* Carried for the same reason health.js reads it: min_diff is the + * rate-loop bound, which the network difficulty still clamps, + * while promised_min_diff is KEPT when the chain is easier. Only + * the second one costs a miner blocks, so only the second one + * earns the warning the connect card prints. A proxy predating + * the field publishes nothing, which reads as 0 — no promise. */ + promised_min_diff: + Number.isFinite(Number(l.promised_min_diff)) + ? Number(l.promised_min_diff) : 0, })); return out.length ? out : null; } diff --git a/dashboard/test/connect-ports.test.js b/dashboard/test/connect-ports.test.js new file mode 100644 index 0000000..d560d16 --- /dev/null +++ b/dashboard/test/connect-ports.test.js @@ -0,0 +1,156 @@ +/* The "which port" block on the connect card. + * + * A stratum URL says nothing about the difficulty behind it, and pointing a + * rented fleet at the home-miner port is not a subtle failure: one connection + * carrying a whole fleet at difficulty 1 is hundreds of thousands of submits + * a second, the pool limits it, and the marketplace cancels the order for + * work the pool appears to be rejecting. So the ports are listed with what + * each is for. + * + * The second property here is the one the operator-facing health check + * already reports and the miner never saw: a port holding a floor ABOVE the + * network difficulty makes its miners discard blocks they solved. That is + * the miner's loss, so it is disclosed to the miner. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs'; +import os from 'node:os'; +import ejs from 'ejs'; +import Database from 'better-sqlite3'; + +import * as fmt from '../lib/fmt.js'; +import { poolMeta } from '../lib/stats.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const VIEWS = path.resolve(__dirname, '../views'); +const SCHEMA = path.resolve(__dirname, '../../schema.sql'); +const URL_ = 'stratum+tcp://pool.example.org:3334'; + +const HOME = { port: 3334, label: null, min_diff: 1, initial_diff: 1, + promised_min_diff: 0 }; +const RENTED = { port: 3335, label: 'braiins', min_diff: 65536, + initial_diff: 65536, promised_min_diff: 65536 }; + +const ports = (pool, stratumUrl = URL_) => + ejs.renderFile(path.join(VIEWS, 'partial/connect-ports.ejs'), + { ...fmt.all, pool, stratumUrl }, { views: [VIEWS] }); + +const pool = (listeners, extra = {}) => ({ + pool_mode: 'pplns-coinbase', fee_bps: 100, network: 'signet', + network_difficulty: 1e12, listeners, ...extra, +}); + +test('each port is printed as a dialable URL with who it is for', async () => { + const html = await ports(pool([HOME, RENTED])); + assert.match(html, /stratum\+tcp:\/\/pool\.example\.org:3334\s+individual miners/); + assert.match(html, /stratum\+tcp:\/\/pool\.example\.org:3335\s+rented or aggregated hashrate \(braiins\)/); + assert.match(html, /65,536/); +}); + +test('a pool with one port explains nothing — there is no choice to make', async () => { + assert.equal((await ports(pool([HOME]))).trim(), ''); + assert.equal((await ports(pool(null))).trim(), ''); + assert.equal((await ports(null)).trim(), ''); +}); + +test('the difficulty of a port is not a claim about what it pays', async () => { + /* The fear this answers: "the big-difficulty port must pay less per + * share". It pays the same per unit of difficulty, and a miner who does + * not know that will avoid the port they belong on. */ + const html = await ports(pool([HOME, RENTED])); + assert.match(html, /does not change what you earn/); + assert.match(html, /credited by its difficulty/); +}); + +test('solo does not answer that with a credit it does not pay', async () => { + /* Same reassurance, different reason: in solo a share is credited + * nothing at all, so "work is credited by its difficulty" would be the + * card promising a payment this mode never makes. */ + const html = await ports(pool([HOME, RENTED], { pool_mode: 'solo' })); + assert.match(html, /does not change what you earn/); + assert.doesNotMatch(html, /credited by its difficulty/); + assert.match(html, /only a block\s+pays/); +}); + +test('a floor held above the network difficulty is disclosed, with its size', async () => { + /* 500000 held over a chain at 1200: ~416 of every 417 blocks solved on + * that port are filtered out by the miner before the pool sees them. */ + const nice = { port: 3336, label: 'nicehash', min_diff: 500000, + initial_diff: 500000, promised_min_diff: 500000 }; + const html = await ports(pool([HOME, nice], { network_difficulty: 1200 })); + assert.match(html, /A held floor costs blocks/); + assert.match(html, /3336/); + assert.match(html, /416 of every\s+417/); + assert.match(html, /1,200/); +}); + +test('the worst floor is the one reported, not the first', async () => { + const nice = { port: 3336, label: 'nicehash', min_diff: 500000, + initial_diff: 500000, promised_min_diff: 500000 }; + const html = await ports(pool([HOME, RENTED, nice], { network_difficulty: 1200 })); + assert.match(html, /Port 3336<\/strong>/); +}); + +test('a floor the chain is already above costs nothing and says so', async () => { + const html = await ports(pool([HOME, RENTED], { network_difficulty: 1e12 })); + assert.doesNotMatch(html, /A held floor costs blocks/); + assert.match(html, /not in that position right now/); +}); + +test('a pool where no port holds a floor gets neither paragraph', async () => { + /* initial_diff high, promised_min_diff 0: configured, not promised. The + * network difficulty clamps it, so no block is lost and there is nothing + * to disclose. */ + const configured = { port: 3335, label: 'big', min_diff: 1, + initial_diff: 65536, promised_min_diff: 0 }; + const html = await ports(pool([HOME, configured], { network_difficulty: 1200 })); + assert.doesNotMatch(html, /A held floor costs blocks/); + assert.doesNotMatch(html, /not in that position right now/); + /* Still listed, and still on the rented side of the list. */ + assert.match(html, /rented or aggregated hashrate \(big\)/); +}); + +test('no published stratum URL yields a placeholder host, not a wrong one', async () => { + const html = await ports(pool([HOME, RENTED]), ''); + assert.match(html, /stratum\+tcp:\/\/<pool-host>:3335/); + assert.doesNotMatch(html, /example\.org/); +}); + +test('a forknet difficulty is not rounded away to zero', async () => { + const tiny = { port: 3335, label: 'tiny', min_diff: 3e-10, + initial_diff: 3e-10, promised_min_diff: 3e-10 }; + const html = await ports(pool([HOME, tiny], { network_difficulty: 1e-12 })); + assert.match(html, /3e-10/); + assert.doesNotMatch(html, /difficulty 0,/); +}); + +test('a proxy that never published promised_min_diff promises nothing', async () => { + /* An older proxy writes port/label/min_diff/initial_diff only. Reading a + * missing promise as a floor would print a block-loss warning at every + * miner of a pool that is not losing any. Goes through the real + * pool_meta read rather than a hand-built object, because the default is + * parseListeners' to get wrong. */ + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'sp-ports-')), + 'shares.db'); + const db = new Database(file); + db.exec(fs.readFileSync(SCHEMA, 'utf8')); + db.prepare(`INSERT INTO pool_meta + (id, pool_mode, fee_bps, rate_source, rate_sats_per_diff, + gross_sats_per_diff, effective_fee_bps, network_difficulty, + block_value_sats, credited_from, listeners, updated_at) + VALUES (1, 'pplns-coinbase', 100, 'derived', 0, 0, 100, 1200, + 312500000, 1, ?, 1)`) + .run(JSON.stringify([ + { port: 3334, label: '', min_diff: 1, initial_diff: 1 }, + { port: 3335, label: 'old', min_diff: 65536, initial_diff: 65536 }, + ])); + const meta = poolMeta(db); + assert.equal(meta.listeners[1].promised_min_diff, 0); + + const html = await ports(meta); + assert.match(html, /rented or aggregated hashrate \(old\)/); + assert.doesNotMatch(html, /A held floor costs blocks/); +}); diff --git a/dashboard/views/partial/about-numbers.ejs b/dashboard/views/partial/about-numbers.ejs index 80a5af4..1592c00 100644 --- a/dashboard/views/partial/about-numbers.ejs +++ b/dashboard/views/partial/about-numbers.ejs @@ -315,4 +315,10 @@ password: (ignored — any value) username: <address>[.<rig_label>] (see above) password: (ignored — any value) <% } %> + +<%# Which port, appended to every mode's connect block rather than repeated + inside each of them: the choice is about difficulty, and difficulty is the + one thing the five modes agree on. Renders nothing unless the proxy + published more than one port. %> +<%- include('connect-ports', { pool: _p, stratumUrl: _url }) %>

    diff --git a/dashboard/views/partial/connect-ports.ejs b/dashboard/views/partial/connect-ports.ejs new file mode 100644 index 0000000..1dfcbfa --- /dev/null +++ b/dashboard/views/partial/connect-ports.ejs @@ -0,0 +1,141 @@ +<%# Which port to point which machine at, and what the high-difficulty one costs. + + A stratum URL says nothing about the difficulty behind it, and the two + ports are not interchangeable: a marketplace does not arrive as many small + miners, it aggregates a whole fleet behind ONE connection, so the same + hashrate that is 12 shares a minute from a home ASIC is hundreds of + thousands of submits per second on the port meant for that ASIC. That is + not a subtle failure — the pool rate-limits the connection, the rejects + look like a broken pool, and the order is cancelled. + + The card the identity strip cannot be: the strip has room to name the + ports, not to say why there are two of them or what the floor on the + second one costs the miner who uses it. + + Rendered only when the proxy published more than one port. A single-port + pool has no choice to explain, and inventing a second port's advice for it + would be worse than silence. + + Locals: pool, stratumUrl. %> +<% +const _p = (typeof pool !== 'undefined' && pool) ? pool : null; +const _ls = (_p && Array.isArray(_p.listeners)) ? _p.listeners : null; +%> +<% if (_ls && _ls.length > 1) { %> +<% +/* Each port is printed as a whole URL rather than a bare number, because the + * line is meant to be copied into a miner's config, not transcribed. The host + * comes from the same PUBLIC_STRATUM_URL the card above prints, so a pool + * that has not set one gets the same placeholder here instead of + * a confident URL pointing nowhere. */ +const _raw = (typeof stratumUrl !== 'undefined' && stratumUrl) ? String(stratumUrl) : ''; +const _u = _raw.match(/^([A-Za-z0-9+.\-]+:\/\/)?([^\s\/:]+)/); +const _sch = (_u && _u[1]) ? _u[1] : 'stratum+tcp://'; +const _host = (_u && _u[2]) ? _u[2] : ''; + +/* What the port asks the chain for is the higher of its starting difficulty + * and its vardiff floor — a port that starts at 1 but is floored at 500000 + * still ends up serving 500000, and that is the confusing configuration to + * debug, not the obvious one. */ +const _asks = l => Math.max(Number(l.initial_diff || 0), Number(l.min_diff || 0)); +const _promised = l => Number(l.promised_min_diff || 0); + +/* Difficulty is not necessarily >= 1: on a forknet these are values like + * 3e-10, and rounding every one of them to "0" would make the whole list + * meaningless. Whole numbers print plainly, small ones in the exponent form + * they were configured with. */ +const _d = x => (x >= 1 ? Math.round(x).toLocaleString('en-US') + : String(Number(Number(x).toPrecision(3)))); + +const _rows = _ls.map(l => { + const url = _sch + _host + ':' + l.port; + const held = _promised(l) > 0; + const ask = _asks(l); + let what; + if (ask <= 0) what = 'default difficulty'; + else if (held) what = 'difficulty ' + _d(ask) + ', held'; + else what = 'difficulty from ' + _d(ask) + ', vardiff follows your rig'; + const who = held || ask >= 1024 + ? 'rented or aggregated hashrate' + : 'individual miners'; + /* Who the port is for comes first: that is the word a miner is scanning + * this list for, and the difficulty is what they check afterwards. */ + return { url, text: who + (l.label ? ' (' + l.label + ')' : '') + ' — ' + what }; +}); +const _w = _rows.reduce((m, r) => Math.max(m, r.url.length), 0); + +/* The disclosure a miner is owed before they point a rig at a floored port, + * and it can only be made when the chain is actually easier than the floor — + * which is a live comparison, not a property of the config. Same arithmetic + * as the operator-facing health check, addressed to the other party. */ +const _net = Number(_p.network_difficulty || 0); +const _kept = _net > 0 ? _ls.filter(l => _promised(l) > _net) : []; +const _worst = _kept.length + ? _kept.reduce((a, b) => (_promised(a) > _promised(b) ? a : b)) + : null; +const _ratio = _worst ? _promised(_worst) / _net : 0; +/* A pool with no floored port at all gets neither paragraph. Explaining what + * a held floor costs, on a pool where nothing holds one, is noise dressed as + * disclosure. */ +const _anyFloor = _ls.some(l => _promised(l) > 0); +%> +

    Which port to connect to

    +
    <% _rows.forEach(r => { %><%= r.url.padEnd(_w) %>   <%= r.text %>
    +<% }) %>
    +

    + Which port you use does not change what you earn. + <% if (_p.pool_mode === 'solo') { %> + Nothing is credited between blocks in this mode — only a block + pays — and being served a larger difficulty does not make one + harder to find. It changes how often your miner reports in, not + how often it wins. + <% } else { %> + Work is credited by its difficulty, so one share at 65,536 counts + exactly as 65,536 shares at difficulty 1. The ports differ in the + size of the unit, not in its price. + <% } %> +

    +

    + They exist because one difficulty cannot serve both kinds of miner. + Rented hashrate does not arrive as a crowd of small rigs — a + marketplace aggregates a whole fleet behind a single + connection, and 1 PH/s at difficulty 1,024 is already about 227 + shares per second down that one socket. On a port meant for a home + ASIC the same fleet is hundreds of thousands per second: the pool + limits the connection, and what the marketplace sees is a pool + rejecting its work. +

    +

    + Vardiff cannot repair that after the fact. It moves by at most 4× per + window, so climbing from 1 to 65,536 takes eight windows — around four + minutes — and the rejects on the way up are what gets an order + cancelled. The connection has to arrive at the right + difficulty, which is what the second port is for. +

    + <% if (_worst) { %> +

    + A held floor costs blocks, and it is your blocks it + costs. Port <%= _worst.port %><% if (_worst.label) { %> + (<%= _worst.label %>)<% } %> holds difficulty + <%= _d(_promised(_worst)) %> while the network is at + only <%= _d(_net) %>. Your miner filters locally + against the difficulty it was given, so it throws away roughly + <%= (_ratio - 1).toFixed(0) %> of every + <%= _ratio.toFixed(0) %> blocks it solves before this pool + ever sees them. The floor is held deliberately — a marketplace + measures the difficulty on the wire and cancels an order served under + what the port advertised — but if you are not mining under a + marketplace rule, connect on a port without a floor and keep every + block you find. +

    + <% } else if (_anyFloor) { %> +

    + A port with a held floor keeps it even when the chain is easier than + the floor, because a marketplace measures the difficulty on the wire + and cancels an order served under what the port advertised. When that + happens your miner discards solutions the chain would have accepted, + so mine on a floored port only if something is actually requiring it + of you. This pool is not in that position right now. +

    + <% } %> +<% } %> From 0188572a204ac622f0074c02a774699796ed90ef Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 10 Sep 2026 23:45:00 +0200 Subject: [PATCH 35/36] dashboard: the templates page priced shares for a mode the pool is not in The PPS rate row answered a zero rate with "only pps-classic prices a share on arrival". True of pps-classic, and no answer at all to the pplns-coinbase operator looking at their own pool and wondering what is n/a and why -- on the page you open when something looks wrong. The zero itself is correct: refresh_pps_rate() stores 0 in every mode but pps-classic, because PPLNS prices a share in hindsight out of a block actually found. Only the wording was wrong, and it was wrong in a familiar way. Three places used to answer "not pps-classic" with the word solo; this row was the fourth, and the 0.4.0 changelog already claimed it fixed. That bullet is corrected here too. The row now names the mode the pool is actually in, the label stops calling itself a PPS rate on a pool that has none, and for the PPLNS rails the card says where the price does come from: the block value above it is what gets divided, among the window, when a block is found. Under pps-classic a zero keeps its own meaning -- accrual gated by the difficulty floor -- rather than being softened into an n/a. The history table drops its rate column when no row was ever priced. It keys off the data rather than the current mode, so a pool that switched away from pps-classic keeps its priced history legible. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PkTEy3SBLUZp1j3kGdtRE7 --- CHANGELOG.md | 10 ++- .../test/pplns-coinbase-disclosure.test.js | 75 ++++++++++++++++++- dashboard/views/templates.ejs | 43 ++++++++++- 3 files changed, 122 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bbde6e..ac04b5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,8 +92,16 @@ listener = port=3335 label=rental min_diff=500000 initial_diff=500000 max_coinba all three PPLNS modes fell through to *"this pool has not published its mode yet"*, directly beneath a header that named the mode correctly. - Three places answered "not `pps-classic`" with the word *solo*: the worker - page's **Owed** field, the templates page's PPS rate, and the + page's **Owed** field, the "About the numbers" card, and the `pps_difficulty` health check. +- The templates page's **PPS rate** row was the fourth, and this bullet used + to claim it fixed. It answered a zero rate with *"only pps-classic prices a + share on arrival"* — a true sentence about a mode the pool is not in, on the + page an operator opens when something looks wrong. It now names the pool's + own mode, and for the PPLNS rails says where the price does come from: the + block value above it is what gets divided, among the window, when a block is + found. The label stops calling itself a PPS rate on a pool that has none, + and the history table drops the rate column when no row was ever priced. - **Pool solvency** counted `blocks_found.reward_sats` as pool revenue in `pplns-coinbase`, where that is what the block paid the *miners* — reporting a healthy margin for a pool that holds nothing. Now skipped, with the reason. diff --git a/dashboard/test/pplns-coinbase-disclosure.test.js b/dashboard/test/pplns-coinbase-disclosure.test.js index c07302a..f160068 100644 --- a/dashboard/test/pplns-coinbase-disclosure.test.js +++ b/dashboard/test/pplns-coinbase-disclosure.test.js @@ -24,7 +24,7 @@ import { fileURLToPath } from 'node:url'; import Database from 'better-sqlite3'; import ejs from 'ejs'; -import { poolMeta, fmtHashrate } from '../lib/stats.js'; +import { poolMeta, fmtHashrate, templates as statsTemplates } from '../lib/stats.js'; import { health as runHealth } from '../lib/health.js'; import * as fmt from '../lib/fmt.js'; @@ -180,3 +180,76 @@ test('a mode with no balance does not report one as owed', async () => { 'a pplns-coinbase worker page must not claim solo'); assert.match(html, /paid in the coinbase/); }); + +/* The fourth place. templates.ejs answered a zero rate with "only pps-classic + * prices a share on arrival" -- true of pps-classic, and no answer at all to + * the pplns operator who is looking at their own pool and wondering what is + * n/a and why. Same bug as the three above, one page later. */ +function withTemplate(db, { rate = 0, height = 997058 } = {}) { + db.prepare(`INSERT INTO templates + (ts, height, prev_hash, bits, network_difficulty, + coinbase_value_sats, tx_count, tx_fees_sats, source, + cb_spendable, cb_op_returns, longpoll, rate_sats_per_diff, + last_seen, polls) + VALUES (@ts, @height, @prev, '1900ffff', 4294967296, + 313374735, 1158, 874735, 'enforcer', 1, 8, 1, @rate, + @ts, 62)`) + .run({ ts: Math.floor(Date.now() / 1000), height, rate, + prev: '00'.repeat(32) }); + return db; +} + +const templatesPage = db => render('templates.ejs', { + templates: statsTemplates(db), pool: poolMeta(db), + health: { ok: true, checks: [] }, + stratumUrl: 'stratum+tcp://x:3334', sidechainId: 9, +}); + +test('the templates page names the mode it is in, not pps-classic', async () => { + for (const mode of ['pplns-coinbase', 'pplns-btc', 'pplns-thunder', 'solo']) { + const html = await templatesPage(withTemplate(makeDb({ mode }))); + assert.doesNotMatch(html, /only pps-classic prices a share on arrival/, + `${mode} was told about a mode it is not in`); + assert.match(html, new RegExp(mode), `${mode} should name itself`); + /* And the label must stop claiming a PPS rate this pool has none of. */ + assert.doesNotMatch(html, /