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();