From b085a230f04b08e2e31114a1f1b6b396357684a6 Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 21 Aug 2026 17:10:02 +0200 Subject: [PATCH] Widen extranonce2 to 8 bytes and enforce the width on submit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extranonce2 was 4 bytes, hardcoded in two places: the literal handed back as the third element of the mining.subscribe result, and the /*en2*/ 4 passed into stratum_job_new. Both now come from STRATUM_EXTRANONCE2_SIZE in stratum.h, and the value is 8. The reason is not search space. At 4 bytes a single connection already gets 2^80 headers per job once nonce and version rolling are counted, which no hashrate exhausts and which jobs rotate out from under anyway. The reason is subdivision: a stratum proxy in front of the pool splits the extranonce2 it is given into a downstream-miner id plus the downstream miner's own extranonce2. At 4 bytes a proxy spending 3 on addressing leaves its miners one byte, and some firmware will not run that narrow. At 8 it can spend 3 and still hand down the conventional 4. Widening it exposed a hole that was already there. handle_submit took whatever extranonce2 length the miner sent and spliced it into a cb1 whose scriptSig length varint had been fixed at render time from en1_size + en2_size. A mismatched width produces a coinbase whose declared scriptSig length disagrees with the bytes following it — an invalid transaction whose header still hashes like a valid one, so the share is credited and only a found block reveals the problem, by being rejected. Before this change that took a misbehaving miner; after it, any miner that ignores mining.subscribe and assumes the classic 4 hits it. So submit now rejects on length ("wrong extranonce2 size") instead of silently accepting work that could never become a block. Also add the 100-byte scriptSig cap check to coinbase_build_split. The coinbasetxn path has always had it; the from-scratch path computed script_sig_len and never checked it. Not reachable through config today (a 5-byte height push plus a 76-byte tag plus 12 extranonce bytes is 93), but it is the guard that keeps a future widening from emitting a coinbase that only fails at the network. Tests cover the advertised size, rejection of a too-narrow and a too-wide extranonce2, acceptance at the exact width, agreement between the scriptSig varint and the assembled coinbase at the production width, that a wrong-width splice does not parse as a valid coinbase, and the cap check. The regtest walkthrough against the enforcer was not run — no local bitcoind/enforcer. --- NONCE_AND_SHARES.md | 73 ++++++++++++++++++++------- README.md | 2 +- docs/simplepool.html | 20 +++++++- src/coinbase.c | 13 +++++ src/main.c | 2 +- src/stratum.c | 42 +++++++++++++--- src/stratum.h | 28 ++++++++++- tests/test_coinbase.c | 112 ++++++++++++++++++++++++++++++++++++++++++ tests/test_stratum.c | 90 ++++++++++++++++++++++++++++----- 9 files changed, 341 insertions(+), 41 deletions(-) diff --git a/NONCE_AND_SHARES.md b/NONCE_AND_SHARES.md index 26b8901..c27796b 100644 --- a/NONCE_AND_SHARES.md +++ b/NONCE_AND_SHARES.md @@ -44,19 +44,50 @@ simplepool uses the standard stratum-v1 split: ``` coinbase scriptSig layout (assembled at share-check time): - [ height_push ] [ tag ] [ extranonce1 (4 B) ][ extranonce2 (4 B) ] + [ height_push ] [ tag ] [ extranonce1 (4 B) ][ extranonce2 (8 B) ] └── pool assigns ──┘└── miner picks ──┘ ``` - **extranonce1 (4 bytes, `en1`)** — assigned by the pool when the connection subscribes. Immutable for the life of that TCP session. -- **extranonce2 (4 bytes, `en2`)** — the miner's private search field. +- **extranonce2 (8 bytes, `en2`)** — the miner's private search field. Each `mining.submit` carries an `en2` value; the miner sweeps it independently. -Together they give each connection **2⁶⁴ distinct coinbases** to try -before it has to reconnect for a fresh `en1`. At any real hashrate, -that's effectively unbounded. +Both widths come from `STRATUM_EXTRANONCE1_SIZE` / +`STRATUM_EXTRANONCE2_SIZE` in `src/stratum.h`; the `en2` size is what +the pool reports as the third element of the `mining.subscribe` result. + +`en1` is fixed for the life of the connection, so each connection has +**2⁶⁴ distinct coinbases** to try before it would need to reconnect for +a fresh one; across the pool the `(en1, en2)` pair space is 2⁹⁶. At any +real hashrate, both are effectively unbounded. + +#### Why extranonce2 is 8 bytes and not the classic 4 + +Not for search space. Even at 4 bytes a single connection gets 2⁸⁰ +headers per job (see the version-rolling section below), which a 1 EH/s +farm would take about two weeks to exhaust — and jobs rotate every +template. Capacity was never the constraint. + +The reason is **subdivision**. A stratum proxy sitting between the pool +and a farm fans one upstream connection out to many downstream miners +by splitting the `en2` field it was given: high bytes become a +downstream-miner id, low bytes are passed down as that miner's own +`en2`. At 4 bytes upstream, a proxy spending 3 on addressing leaves its +miners a single byte — 256 values — and some firmware refuses to run +that narrow. At 8 the proxy can spend 3 and still hand down the +conventional 4, so every downstream miner sees an ordinary pool. + +**Changing these widths is consensus-relevant, not cosmetic.** `cb1` +ends with the scriptSig length varint, computed once from +`en1_size + en2_size` when the coinbase is rendered. An `en2` of any +other width produces a coinbase whose declared scriptSig length +disagrees with the bytes that follow it — an invalid transaction whose +header nonetheless hashes fine. `handle_submit` therefore rejects any +submission whose `en2` is not exactly `job->en2_size` +(`src/stratum.c`, error `wrong extranonce2 size`) rather than crediting +a share for work that could never become a block. For each `en2` the miner picks, it then sweeps the header's 4-byte `nonce` field (2³² hashes) and, if version-rolling was negotiated, @@ -65,27 +96,32 @@ also permutes the masked version bits. So each `en2` value gives ### Extranonce1 allocation — how uniqueness is guaranteed -`src/stratum.c:688-694`: +In `handle_subscribe` (`src/stratum.c`): ```c -/* Allocate extranonce1 from server counter ^ time. */ +/* Take extranonce1 straight from the server counter. */ unsigned seq = atomic_fetch_add(&s->extranonce1_seq, 1); -uint32_t mix = seq ^ (uint32_t)now_ms(); +uint32_t mix = (uint32_t)seq; c->extranonce1[0] = (uint8_t)(mix >> 24); c->extranonce1[1] = (uint8_t)(mix >> 16); c->extranonce1[2] = (uint8_t)(mix >> 8); c->extranonce1[3] = (uint8_t)mix; ``` -Two properties matter: - -1. **Uniqueness across concurrent connects** — `atomic_fetch_add` on - `extranonce1_seq` guarantees that no two connections can read the - same `seq` value even if they subscribe in the same nanosecond. -2. **Freshness after counter wrap** — the 32-bit `seq` will wrap - after 4.3 billion connections. XORing with `now_ms()` (also 32 - bits) ensures that even if a rig disconnects and reconnects days - later after the counter has cycled, it gets a different `en1`. +**Uniqueness across concurrent connects** — `atomic_fetch_add` on +`extranonce1_seq` guarantees that no two connections can read the same +`seq` value even if they subscribe in the same nanosecond. The counter +is seeded from the clock once at startup, so a restart doesn't hand out +the same values to a fresh set of connections. + +The counter is *not* mixed with the clock at use. An earlier version +did (`seq ^ now_ms()`), and that destroyed the uniqueness guarantee it +was meant to reinforce: the XOR collides whenever the delta in the +clock equals the delta in the counter — an even `seq` at an even +millisecond and the next `seq` one millisecond later land on the same +value. A miner opening several connections at once hit that routinely, +and two connections sharing an `en1` render identical coinbases, so +both find the same hash from the same nonce. **No two miners on the pool are searching the same `(header, coinbase, nonce)` triple.** That's the fairness guarantee @@ -116,7 +152,8 @@ into the header's `version` field. Current default mask: **`0x1fffe000`** — the 16 bits between position 13 and 28. That expands the effective per-`(en1, en2)` search space by 2¹⁶, so a single `en2` value covers 2³² × 2¹⁶ = 2⁴⁸ ≈ 280 trillion -headers. +headers, and one connection covers 2⁶⁴ × 2⁴⁸ = 2¹¹² over the full `en2` +sweep. **The pool never re-uses a version-rolled header for share validation**: on submit, the miner tells us the exact rolled version diff --git a/README.md b/README.md index 498606f..90bb41c 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ sequenceDiagram Note over M,P: per-connection setup M->>P: TCP connect :3334 M->>P: mining.subscribe - P-->>M: extranonce1 (4B, per-conn), en2_size + P-->>M: extranonce1 (4B, per-conn), en2_size (8B) M->>P: mining.authorize "[.rig_label]" P->>P: validate bech32/base58 → cache payout_address
arm vardiff window P-->>M: result: true diff --git a/docs/simplepool.html b/docs/simplepool.html index e48cf4d..5050de5 100644 --- a/docs/simplepool.html +++ b/docs/simplepool.html @@ -682,7 +682,7 @@

Where the extranonce lives

height pushBIP34
coinbase_tage.g. /simplepool/
extranonce14 B · pool assigns
-
extranonce24 B · miner sweeps
+
extranonce28 B · miner sweeps

assigned once per connection @@ -697,6 +697,24 @@

Where the extranonce lives

distinct coinbase txid, therefore a distinct merkle root, therefore a fresh 232 nonce space to sweep.

+

+ extranonce2 is 8 bytes rather than the classic 4, and the + reason is not search space — 4 bytes already outruns any hashrate. It is so + that a stratum proxy in front of the pool can subdivide the field, taking + the high bytes as a downstream-miner id and passing the low bytes down as + that miner's own extranonce2. At 4 bytes a proxy spending 3 on + addressing leaves its miners a single byte, which some firmware refuses to + run with; at 8 it can spend 3 and still hand down the conventional 4. +

+

+ The width is not advisory. cb1 ends with the scriptSig length + varint, fixed at render time from the two extranonce sizes, so an + extranonce2 of any other width yields a coinbase whose declared + length disagrees with its contents — an invalid transaction that still + hashes like a valid one. The pool rejects such submissions + (wrong extranonce2 size) rather than credit a share for work + that could never become a block. +

Version rolling

diff --git a/src/coinbase.c b/src/coinbase.c index 087b6d6..85013dc 100644 --- a/src/coinbase.c +++ b/src/coinbase.c @@ -522,6 +522,19 @@ int coinbase_build_split(uint32_t height, int64_t value_sats, size_t en_total = extranonce1_size + extranonce2_size; size_t script_sig_len = height_push_len + tag_push_len + en_total; + /* Consensus caps the coinbase scriptSig at 100 bytes; a block that + * exceeds it is rejected outright. The budget is the BIP34 height push + * plus the operator's coinbase_tag (up to 76 bytes with its length byte) + * plus both extranonces, so a long tag and a wide extranonce can reach it + * together. The coinbasetxn path below checks this already -- check here + * too rather than emitting a coinbase that only fails at the network. */ + if (script_sig_len < 2 || script_sig_len > 100) { + 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; + } + /* Build outputs blob: miner payout, [operator fee], [witness commitment]. */ bbuf_t outs; bbuf_init(&outs); diff --git a/src/main.c b/src/main.c index ff83d93..3cbb59e 100644 --- a/src/main.c +++ b/src/main.c @@ -233,7 +233,7 @@ static stratum_job_t *build_job_from_template(const proxy_config_t *cfg, job_id, t->version, prev_le, t->coinbase_value_sats, t->default_witness_commitment, - /*en1*/ 4, /*en2*/ 4, + STRATUM_EXTRANONCE1_SIZE, STRATUM_EXTRANONCE2_SIZE, (const uint8_t (*)[32])branches, branch_count, t->bits, t->curtime, target_be, (uint32_t)t->height, diff --git a/src/stratum.c b/src/stratum.c index 95c92a4..17d5789 100644 --- a/src/stratum.c +++ b/src/stratum.c @@ -200,7 +200,7 @@ struct stratum_conn { pthread_t thr; int thr_started; - uint8_t extranonce1[4]; + uint8_t extranonce1[STRATUM_EXTRANONCE1_SIZE]; double difficulty; int subscribed; int authorized; @@ -746,8 +746,8 @@ static int handle_subscribe(stratum_server_t *s, stratum_conn_t *c, cJSON *id, c->extranonce1[3] = (uint8_t)mix; c->subscribed = 1; - char ex1_hex[9]; - bytes_to_hex(c->extranonce1, 4, ex1_hex); + char ex1_hex[STRATUM_EXTRANONCE1_SIZE * 2 + 1]; + bytes_to_hex(c->extranonce1, sizeof c->extranonce1, ex1_hex); cJSON *result = cJSON_CreateArray(); cJSON *subs = cJSON_CreateArray(); @@ -761,7 +761,10 @@ static int handle_subscribe(stratum_server_t *s, stratum_conn_t *c, cJSON *id, cJSON_AddItemToArray(subs, sn); cJSON_AddItemToArray(result, subs); cJSON_AddItemToArray(result, cJSON_CreateString(ex1_hex)); - cJSON_AddItemToArray(result, cJSON_CreateNumber(4)); + /* The miner sizes its extranonce2 sweep off this number, and the coinbase + * we render for it reserves exactly this many bytes. handle_submit + * enforces the agreement. */ + cJSON_AddItemToArray(result, cJSON_CreateNumber(STRATUM_EXTRANONCE2_SIZE)); return emit_response(buf, len, id, result, NULL); } @@ -1055,6 +1058,28 @@ static int handle_submit(stratum_server_t *s, stratum_conn_t *c, cJSON *id, return emit_response(buf, len, id, NULL, err); } + /* The extranonce2 must be exactly the width we advertised on subscribe + * and reserved when rendering this job's cb1. cb1 ends with the scriptSig + * length varint, which was computed as en1_size + en2_size; splicing in a + * different width produces a coinbase whose declared scriptSig length + * disagrees with the bytes that follow it. That transaction is invalid, + * so any block built on it is rejected by the network -- but its header + * still hashes, so without this check the share would look fine and be + * credited. Reject instead of silently mining garbage: a miner that + * ignored the advertised size needs to hear about it. */ + if (en2_len != job->en2_size) { + free(en2_bytes); + if (s->cfg.on_reject) { + s->cfg.on_reject(s->cfg.ctx, c->worker_name, now_ms(), + "wrong extranonce2 size"); + } + LOG_WARN("submit from %s: extranonce2 is %zu bytes, expected %zu " + "(miner ignored the size from mining.subscribe)", + c->worker_name, en2_len, job->en2_size); + cJSON *err = make_error(20, "wrong extranonce2 size"); + return emit_response(buf, len, id, NULL, err); + } + /* Render this connection's coinbase for `job` if not cached. The * cache is keyed on job_id; submits against an older job retired into * the recent ring will rebuild on demand. */ @@ -1069,13 +1094,14 @@ static int handle_submit(stratum_server_t *s, stratum_conn_t *c, cJSON *id, } /* coinbase = cb1 || ex1 || ex2 || cb2 */ - size_t cb_len = c->cb1_len + 4 + en2_len + c->cb2_len; + size_t en1_len = sizeof c->extranonce1; + size_t cb_len = c->cb1_len + en1_len + en2_len + c->cb2_len; uint8_t *cb = malloc(cb_len); if (!cb) { free(en2_bytes); return -1; } size_t off = 0; - memcpy(cb + off, c->cb1, c->cb1_len); off += c->cb1_len; - memcpy(cb + off, c->extranonce1, 4); off += 4; - memcpy(cb + off, en2_bytes, en2_len); off += en2_len; + memcpy(cb + off, c->cb1, c->cb1_len); off += c->cb1_len; + memcpy(cb + off, c->extranonce1, en1_len); off += en1_len; + memcpy(cb + off, en2_bytes, en2_len); off += en2_len; memcpy(cb + off, c->cb2, c->cb2_len); off += c->cb2_len; free(en2_bytes); diff --git a/src/stratum.h b/src/stratum.h index 004d07c..da93526 100644 --- a/src/stratum.h +++ b/src/stratum.h @@ -7,13 +7,39 @@ typedef struct stratum_job stratum_job_t; +/* The extranonce split, advertised on mining.subscribe and baked into every + * per-connection coinbase. + * + * extranonce1 is the pool's per-connection identifier; it must be unique + * across live connections or two miners render identical coinbases (see + * handle_subscribe). extranonce2 is the miner's private search field: it + * owns those bytes and sweeps them freely. + * + * extranonce2 is 8 bytes rather than the classic 4. Raw search space is not + * the reason -- 4 bytes already gives one connection 2^80 headers per job, + * which no hashrate exhausts. The reason is subdivision: a stratum proxy in + * front of the pool carves extranonce2 into a downstream-miner id (high + * bytes) plus the downstream miner's own extranonce2 (low bytes). At 4 bytes + * a proxy spending 3 on addressing leaves its miners a single byte, which + * some firmware refuses to run with. At 8 it can spend 3 and still hand down + * the conventional 4, so downstream miners see an ordinary pool. + * + * Widening these is a consensus-relevant change, not a cosmetic one: cb1 + * carries the scriptSig length varint computed from en1_size + en2_size, so + * a submitted extranonce2 of any other length yields a coinbase whose + * declared scriptSig length disagrees with its contents. handle_submit + * rejects on length for exactly that reason. Keep the coinbase scriptSig + * (BIP34 height push + coinbase_tag + en1 + en2) within 100 bytes. */ +#define STRATUM_EXTRANONCE1_SIZE 4 +#define STRATUM_EXTRANONCE2_SIZE 8 + /* Create a job from template fields. The coinbase is *not* baked into the * job — each connection renders its own coinbase paying its miner address * (minus the configured operator fee). The job carries everything else * the server needs to materialise a per-connection coinbase on demand: * - value_sats: coinbasevalue from getblocktemplate * - witness_commitment_hex: optional, may be NULL - * - en1_size / en2_size: extranonce sizes, both currently 4 + * - en1_size / en2_size: extranonce sizes; see STRATUM_EXTRANONCE*_SIZE * * tx_hex_list may be NULL if tx_count == 0. The job takes ownership of * its own heap copies; caller's buffers are not retained. diff --git a/tests/test_coinbase.c b/tests/test_coinbase.c index 0418633..5623341 100644 --- a/tests/test_coinbase.c +++ b/tests/test_coinbase.c @@ -1,4 +1,5 @@ #include "coinbase.h" +#include "stratum.h" #include #include @@ -475,6 +476,114 @@ static void test_count_outputs(void) { printf("ok: coinbase_count_outputs\n"); } +/* The scriptSig length varint lives in cb1, and is computed from + * en1_size + en2_size at render time. If the extranonces spliced in later + * are not exactly that wide, the varint disagrees with the bytes that follow + * and the transaction is malformed -- valid-looking to a hasher, rejected by + * the network. Pin the agreement at the width the pool actually advertises, + * so a change to STRATUM_EXTRANONCE2_SIZE that misses a call site fails here + * rather than on a found block. */ +static void test_scriptsig_length_matches_advertised_extranonce(void) { + const size_t en1 = STRATUM_EXTRANONCE1_SIZE; + const size_t en2 = STRATUM_EXTRANONCE2_SIZE; + + coinbase_parts_t parts = {0}; + char err[256] = {0}; + assert(coinbase_build_split(800000, 5000000000LL, ENF_ADDR, ENF_ADDR, 100, + "6a24aa21a9ed2222222222222222222222222222" + "222222222222222222222222222222222222", + "/simplepool/", + en1, en2, &parts, NULL, NULL, + err, sizeof err) == 0); + + /* Assemble the coinbase exactly as handle_submit does. */ + size_t total = parts.cb1_len + en1 + en2 + parts.cb2_len; + uint8_t *tx = (uint8_t *)malloc(total); + assert(tx); + size_t o = 0; + memcpy(tx + o, parts.cb1, parts.cb1_len); o += parts.cb1_len; + memset(tx + o, 0xaa, en1); o += en1; + memset(tx + o, 0xbb, en2); o += en2; + memcpy(tx + o, parts.cb2, parts.cb2_len); + + /* Walk to the scriptSig varint: version(4) | varint(vin) | prevout(36). */ + size_t off = 4; + uint64_t vin = 0; + assert(read_varint(tx, total, &off, &vin) == 0 && vin == 1); + off += 36; + uint64_t ss_len = 0; + assert(read_varint(tx, total, &off, &ss_len) == 0); + + /* The declared length must cover the real scriptSig contents, and the + * extranonce bytes must be the last thing inside it. */ + assert(ss_len <= 100); + size_t ss_end = off + (size_t)ss_len; + assert(ss_end <= total); + for (size_t i = 0; i < en1; i++) assert(tx[ss_end - en1 - en2 + i] == 0xaa); + for (size_t i = 0; i < en2; i++) assert(tx[ss_end - en2 + i] == 0xbb); + /* Sequence follows immediately -- proof the varint did not run short. */ + for (int i = 0; i < 4; i++) assert(tx[ss_end + i] == 0xff); + + free(tx); + coinbase_parts_free(&parts); + printf("ok: scriptSig length agrees with en1=%zu en2=%zu\n", en1, en2); +} + +/* The same cb1 assembled with a wrong-width extranonce2 -- what a miner that + * ignored mining.subscribe would produce -- must not parse as a well-formed + * coinbase. This is the failure the stratum-side length check prevents. */ +static void test_wrong_width_extranonce_desyncs_the_parse(void) { + coinbase_parts_t parts = {0}; + char err[256] = {0}; + assert(coinbase_build_split(800000, 5000000000LL, ENF_ADDR, ENF_ADDR, 100, + "6a24aa21a9ed2222222222222222222222222222" + "222222222222222222222222222222222222", + "/simplepool/", + STRATUM_EXTRANONCE1_SIZE, + STRATUM_EXTRANONCE2_SIZE, &parts, NULL, NULL, + err, sizeof err) == 0); + + /* Splice in the classic 4-byte extranonce2 instead of the reserved width. */ + size_t n = parts.cb1_len * 2 + (STRATUM_EXTRANONCE1_SIZE + 4) * 2 + + parts.cb2_len * 2 + 1; + char *hex = (char *)malloc(n); + assert(hex); + size_t o = 0; + for (size_t i = 0; i < parts.cb1_len; i++) + o += (size_t)sprintf(hex + o, "%02x", parts.cb1[i]); + for (size_t i = 0; i < STRATUM_EXTRANONCE1_SIZE + 4; i++) + o += (size_t)sprintf(hex + o, "%02x", 0xcc); + for (size_t i = 0; i < parts.cb2_len; i++) + o += (size_t)sprintf(hex + o, "%02x", parts.cb2[i]); + hex[o] = '\0'; + + int spend = -1, opret = -1; + int rc = coinbase_count_outputs(hex, &spend, &opret); + /* Either the parse fails outright or it reports something other than the + * real output set -- never a clean, correct read. */ + assert(!(rc == 0 && spend == 2 && opret == 1)); + + free(hex); + coinbase_parts_free(&parts); + printf("ok: wrong-width extranonce2 does not parse as a valid coinbase\n"); +} + +/* Consensus caps the coinbase scriptSig at 100 bytes. Unreachable through + * config today (height push + a 76-byte tag + 12 extranonce bytes is 93), + * but the guard is what keeps a future widening from emitting a coinbase + * that only fails at the network. */ +static void test_scriptsig_over_100_is_rejected(void) { + coinbase_parts_t parts = {0}; + char err[256] = {0}; + int rc = coinbase_build_split(800000, 5000000000LL, ENF_ADDR, NULL, 0, + NULL, "/simplepool/", + /* en1 */ 4, /* en2 */ 90, + &parts, NULL, NULL, err, sizeof err); + assert(rc < 0); + assert(strstr(err, "scriptSig length") != NULL); + printf("ok: oversized coinbase scriptSig rejected (%s)\n", err); +} + int main(void) { test_p2pkh_address(); test_p2wpkh_address(); @@ -485,6 +594,9 @@ int main(void) { test_build_from_template(); test_build_from_template_fee_split(); test_count_outputs(); + test_scriptsig_length_matches_advertised_extranonce(); + test_wrong_width_extranonce_desyncs_the_parse(); + test_scriptsig_over_100_is_rejected(); printf("test_coinbase: all tests passed\n"); return 0; } diff --git a/tests/test_stratum.c b/tests/test_stratum.c index 60c6d5e..ddecf1d 100644 --- a/tests/test_stratum.c +++ b/tests/test_stratum.c @@ -81,7 +81,7 @@ static stratum_job_t *make_test_job(const char *job_id, return stratum_job_new(job_id, 1, prev, /*value_sats*/ 5000000000LL, /*wc_hex*/ NULL, - /*en1*/ 4, /*en2*/ 4, + STRATUM_EXTRANONCE1_SIZE, STRATUM_EXTRANONCE2_SIZE, NULL, 0, 0x1d00ffffu, 0x60000000u, network_target_be, 800000, NULL, 0, /*coinbasetxn_hex*/ NULL, @@ -115,9 +115,11 @@ static void test_subscribe(void) { cJSON *subs = cJSON_GetArrayItem(result, 0); CHECK(cJSON_IsArray(subs) && cJSON_GetArraySize(subs) == 2); cJSON *ex1 = cJSON_GetArrayItem(result, 1); - CHECK(cJSON_IsString(ex1) && strlen(ex1->valuestring) == 8); + CHECK(cJSON_IsString(ex1) && + strlen(ex1->valuestring) == STRATUM_EXTRANONCE1_SIZE * 2); cJSON *ex2sz = cJSON_GetArrayItem(result, 2); - CHECK(cJSON_IsNumber(ex2sz) && ex2sz->valueint == 4); + CHECK(cJSON_IsNumber(ex2sz) && + ex2sz->valueint == STRATUM_EXTRANONCE2_SIZE); cJSON_Delete(resp); } free(out); @@ -221,7 +223,7 @@ static void test_submit_share_and_dedupe(void) { int rc = stratum_handle_message(s, c, "{\"id\":3,\"method\":\"mining.submit\"," - "\"params\":[\"w\",\"J1\",\"deadbeef\",\"60000000\",\"00000001\"]}", + "\"params\":[\"w\",\"J1\",\"deadbeefcafebabe\",\"60000000\",\"00000001\"]}", &out, &olen); CHECK(rc == 0); CHECK(obs.shares == 1); @@ -232,7 +234,7 @@ static void test_submit_share_and_dedupe(void) { /* Duplicate: same parameters again. */ rc = stratum_handle_message(s, c, "{\"id\":4,\"method\":\"mining.submit\"," - "\"params\":[\"w\",\"J1\",\"deadbeef\",\"60000000\",\"00000001\"]}", + "\"params\":[\"w\",\"J1\",\"deadbeefcafebabe\",\"60000000\",\"00000001\"]}", &out, &olen); CHECK(rc == 0); CHECK(obs.shares == 1); /* not incremented */ @@ -244,6 +246,71 @@ static void test_submit_share_and_dedupe(void) { stratum_server_free(s); } +/* An extranonce2 of any width other than the one advertised on subscribe + * must be rejected, not credited. cb1 carries a scriptSig length varint + * computed from en1_size + en2_size, so a short or long extranonce2 yields a + * coinbase whose declared scriptSig length disagrees with its contents -- + * an invalid transaction. The header over that coinbase still hashes, so an + * unchecked pool would credit the share and only discover the problem when + * the network rejected a block. */ +static void test_submit_rejects_wrong_extranonce2_size(void) { + obs_t obs = {0}; + stratum_cfg_t cfg = { .bind_port = 0, .max_conns = 1, + .initial_diff = 1e-12, /* any hash clears the target */ + .ctx = &obs, .on_share = on_share, + .on_reject = on_reject, .on_block = on_block }; + snprintf(cfg.bind_addr, sizeof(cfg.bind_addr), "127.0.0.1"); + stratum_server_t *s = NULL; + stratum_server_start(&cfg, &s); + + uint8_t net[32] = {0}; + stratum_server_set_job(s, make_test_job("J1", net)); + + stratum_conn_t *c = stratum_conn_new_for_test(s); + char *out = NULL; size_t olen = 0; + stratum_handle_message(s, c, "{\"id\":1,\"method\":\"mining.subscribe\",\"params\":[]}", + &out, &olen); free(out); out=NULL; olen=0; + stratum_handle_message(s, c, + "{\"id\":2,\"method\":\"mining.authorize\"," + "\"params\":[\"" TEST_ADDR "\",\"x\"]}", + &out, &olen); free(out); out=NULL; olen=0; + + /* Four bytes -- what a miner that ignored mining.subscribe and assumed + * the classic width would send. */ + int rc = stratum_handle_message(s, c, + "{\"id\":3,\"method\":\"mining.submit\"," + "\"params\":[\"w\",\"J1\",\"deadbeef\",\"60000000\",\"00000001\"]}", + &out, &olen); + CHECK(rc == 0); + CHECK(obs.shares == 0); + CHECK(obs.rejects == 1); + CHECK(strstr(obs.last_reason, "extranonce2 size") != NULL); + CHECK(out != NULL && strstr(out, "wrong extranonce2 size") != NULL); + free(out); out=NULL; olen=0; + + /* Too wide is equally wrong. */ + rc = stratum_handle_message(s, c, + "{\"id\":4,\"method\":\"mining.submit\"," + "\"params\":[\"w\",\"J1\",\"deadbeefcafebabe00\",\"60000000\",\"00000001\"]}", + &out, &olen); + CHECK(rc == 0); + CHECK(obs.shares == 0); + CHECK(obs.rejects == 2); + free(out); out=NULL; olen=0; + + /* Exactly the advertised width still works. */ + rc = stratum_handle_message(s, c, + "{\"id\":5,\"method\":\"mining.submit\"," + "\"params\":[\"w\",\"J1\",\"deadbeefcafebabe\",\"60000000\",\"00000001\"]}", + &out, &olen); + CHECK(rc == 0); + CHECK(obs.shares == 1); + free(out); + + stratum_conn_free_for_test(c); + stratum_server_free(s); +} + /* Invalid Bitcoin address as the username must be rejected outright. */ static void test_authorize_rejects_non_address(void) { obs_t obs = {0}; @@ -322,7 +389,7 @@ static void test_block_wins_over_low_difficulty(void) { int rc = stratum_handle_message(s, c, "{\"id\":3,\"method\":\"mining.submit\"," - "\"params\":[\"w\",\"J1\",\"deadbeef\",\"60000000\",\"00000001\"]}", + "\"params\":[\"w\",\"J1\",\"deadbeefcafebabe\",\"60000000\",\"00000001\"]}", &out, &olen); CHECK(rc == 0); CHECK(obs.rejects == 0); @@ -373,7 +440,7 @@ static void test_vardiff_clamped_to_network_diff(void) { sleep_ms(1100); stratum_handle_message(s, c, "{\"id\":3,\"method\":\"mining.submit\"," - "\"params\":[\"w\",\"J1\",\"deadbeef\",\"60000000\",\"00000001\"]}", + "\"params\":[\"w\",\"J1\",\"deadbeefcafebabe\",\"60000000\",\"00000001\"]}", &out, &olen); CHECK(out != NULL); CHECK(strstr(out, "mining.set_difficulty") != NULL); @@ -420,7 +487,7 @@ static void test_vardiff_grace_accepts_old_diff_shares(void) { sleep_ms(1100); stratum_handle_message(s, c, "{\"id\":3,\"method\":\"mining.submit\"," - "\"params\":[\"w\",\"J1\",\"deadbeef\",\"60000000\",\"00000001\"]}", + "\"params\":[\"w\",\"J1\",\"deadbeefcafebabe\",\"60000000\",\"00000001\"]}", &out, &olen); CHECK(strstr(out, "mining.set_difficulty") != NULL); CHECK(obs.shares == 1); @@ -430,7 +497,7 @@ static void test_vardiff_grace_accepts_old_diff_shares(void) { * grace window must accept it. */ stratum_handle_message(s, c, "{\"id\":4,\"method\":\"mining.submit\"," - "\"params\":[\"w\",\"J1\",\"deadbeef\",\"60000000\",\"00000002\"]}", + "\"params\":[\"w\",\"J1\",\"deadbeefcafebabe\",\"60000000\",\"00000002\"]}", &out, &olen); CHECK(obs.shares == 2); CHECK(obs.rejects == 0); @@ -562,7 +629,7 @@ static void test_dedupe_same_hash_across_job_ids(void) { stratum_handle_message(s, c, "{\"id\":3,\"method\":\"mining.submit\"," - "\"params\":[\"w\",\"J1\",\"deadbeef\",\"60000000\",\"00000001\"]}", + "\"params\":[\"w\",\"J1\",\"deadbeefcafebabe\",\"60000000\",\"00000001\"]}", &out, &olen); CHECK(obs.shares == 1); free(out); out=NULL; olen=0; @@ -571,7 +638,7 @@ static void test_dedupe_same_hash_across_job_ids(void) { stratum_server_set_job(s, make_test_job("J2", net)); stratum_handle_message(s, c, "{\"id\":4,\"method\":\"mining.submit\"," - "\"params\":[\"w\",\"J2\",\"deadbeef\",\"60000000\",\"00000001\"]}", + "\"params\":[\"w\",\"J2\",\"deadbeefcafebabe\",\"60000000\",\"00000001\"]}", &out, &olen); CHECK(obs.shares == 1); /* still one */ CHECK(obs.rejects >= 1); @@ -587,6 +654,7 @@ int main(void) { test_authorize_triggers_setdiff_notify(); test_submit_unknown_job(); test_submit_share_and_dedupe(); + test_submit_rejects_wrong_extranonce2_size(); test_authorize_rejects_non_address(); test_authorize_address_with_label(); test_block_wins_over_low_difficulty();