Skip to content

gekko: add --gekko-ticket-diff so Hathor tx jobs can be answered - #6

Draft
luislhl wants to merge 5 commits into
masterfrom
fix/gekko-ticket-diff-for-hathor-tx-mining
Draft

gekko: add --gekko-ticket-diff so Hathor tx jobs can be answered#6
luislhl wants to merge 5 commits into
masterfrom
fix/gekko-ticket-diff-for-hathor-tx-mining

Conversation

@luislhl

@luislhl luislhl commented Jul 27, 2026

Copy link
Copy Markdown

Problem

set_ticket() was only ever called with diff = 0.0 — "set the highest valid ticket the chip supports", which is diff 16 on a GSF/Compac F. The call sites state the assumption:

// set ticket based on chips, pool will be above this anyway
set_ticket(compac, 0.0, true, false);

That holds for Bitcoin pools. It does not hold for Hathor tx mining, where jobs arrive at weight 17-32 — difficulties far below 1.

The ticket mask is the minimum difficulty below which the chip will not report a nonce at all. At ticket 16 a device cannot report anything below weight 36, so it can never answer a tx job, regardless of how fast it hashes.

Evidence from production

Measured against mainnet tx-mining-service over 48 h (133 txs), with all three miners' chips confirmed from their own device dumps:

device chip hashrate ticket source of ticket min reportable weight time to first report txs solved
Compac F BM1397 75.8 GH/s 16 set_ticket(0.0) → table max 36 906 ms 0
NewPac BM1387 40 GH/s 1 forced to 0 at driver-gekko.c:1801 32 107 ms 19 of 25 heavy txs
2Pac BM1384 5.1 GH/s 1 hashrate formula rounds to 0 32 842 ms ~0

Production tx lifetimes are bimodal: 81 % at weight ~17 with a median lifetime of 54 ms, 19 % at weight 31-32 with a median of 559 ms.

The Compac F is the fastest silicon in that fleet and the slowest to respond, purely because of a 16× ticket penalty the weaker devices don't pay. Its own log shows it:

0: GSF 0 - set ticket to 0xf0/16 work 128/128.0

At ticket 1 the same device would report in 2^32 / 75.8e9 = ~57 ms, making it the fastest device in the fleet and competitive even on the light txs.

Change

New option, replacing what started as a hardcoded constant:

--gekko-ticket-diff <1-64>   (default: 1, or 0 for the chip's highest valid ticket)

The value is passed straight through to set_ticket() at all 14 call sites, so 0 reproduces stock upstream behaviour exactly and the fork stays usable against Bitcoin pools. Default is 1 because this fork exists to mine Hathor.

Crucially this covers the ticket-validation retry and give-up paths, not just the two init sites. Those also called set_ticket(compac, 0.0, ...), so leaving them alone would silently revert the device to the maximum ticket after a single validation hiccup and quietly undo the fix.

The setter rejects values in (0,1): set_ticket() floors its argument and the lowest ticket_1397[] entry is diff 1, so 0.5 would match no entry, fall through the loop, and leave the ticket unset.

Two hints that diff 1 was the original intent upstream:

  • MAX_TICKET_CHECK is documented as "ticket restart checks allowed before forced to diff=1".
  • One give-up branch still logs "give up - just set it to 1.0" while the code passed 0.0, and every one of those sites carries a commented-out //set_ticket(compac, 1.0, true, true); directly above it.

The diff-1 row of ticket_1397[] validates cleanly, so this does not trip the ticket checker:

  • hi_limit = 0.0 ⇒ the diff < hi_limit "ticket too low" test can never fire.
  • low_limit = 1.9ticket_got_low is set almost immediately, well within the 50-nonce nonce_count.

Build verification

Built and exercised in a clean ubuntu:22.04 container:

./autogen.sh --enable-gekko CFLAGS="-O2 -fcommon"
make
  • Compiles clean; cgminer --versioncgminer 4.13.1
  • --help lists the option with (default: 1.0)
  • 0, 1, 16, 64 accepted; 0.5, 100, -1 rejected with Value out of range - use 0 for the chip maximum, or 1 to 64

-fcommon is required on GCC 10+ and is not specific to this PR — the unmodified upstream tree fails identically without it (multiple definition of 'bab_drv' etc., because the codebase predates -fno-common becoming the default). Verified by building origin/master the same way. Documented in README.

Still to do before this leaves draft

  • Hardware validation on a Compac F. After the patch the driver log should read set ticket to 0x00/1 instead of 0xf0/16, and the device's tx solves should go from zero to competitive. This is the real test, and it doubles as the isolation run in ops-tools#1412.
  • Check nonce report rate is sane. At ticket 1 a 75.8 GH/s device returns ~17.6 nonces/s over USB. The ticket_1397[] comment notes the max-16 default exists "to ensure enough nonces are coming back to identify status changes/issues" — the concern runs the opposite direction here, but worth confirming the USB path and gh/job accounting keep up.

Shipping binaries to the miner operators — done

All three operators run Raspberry Pis, so requiring a local toolchain build was the main friction in getting this onto hardware. This PR now also adds .github/workflows/build-release.yml, which builds and publishes prebuilt binaries.

One binary per architecture covers every model. All GekkoScience devices share driver-gekko.c and are selected at runtime by USB vendor/product id; the --gekko-*-detect options are opt-in allowlists that all default to off, so an unrestricted build detects the 2Pac, NewPac and Compac F alike. Frequency, core voltage and ticket difficulty are all runtime config. No per-model builds needed.

Targets. linux-arm64 (Pi OS 64-bit, uname -m = aarch64), linux-armhf (Pi OS 32-bit, armv7l), linux-amd64 (maintainer reproduction). Shipping both ARM variants means we don't have to collect uname -m from anyone first.

Two deliberate build choices:

  1. Built on debian:bullseye, not the runner's Ubuntu 24.04. glibc is forward- but not backward-compatible; a binary linked against glibc 2.39 will not start on Pi OS Bookworm. Measured glibc floor of the produced binaries is 2.29, so they run on Bullseye and everything newer.
  2. libjansson-dev deliberately not installed, so configure falls back to the bundled compat/jansson-2.9 and links it statically — one less runtime dependency on the miner host. Shipped binaries need only libcurl4, libusb-1.0-0, libncurses6, all normally present on Pi OS.

Every binary is smoke-tested before it ships (.github/docker/smoke-test.sh): --version, option registration and default, argument validation, and — most importantly — driving a config save through the API and asserting the process survives. That last one is a regression test for the SIGSEGV found in review on this branch. write_config() dispatches on the option's callback pointer, so an unregistered setter gets type-punned into strlen(); it crashed on default settings whether or not the new flag was used, and reading the diff would never have caught it.

CI results (run 32774428750, all green): arm64 45s native on ubuntu-24.04-arm, armhf 7m26s under QEMU (GitHub's Cobalt/Ampere cores have no 32-bit support, so emulation is unavoidable there), amd64 59s. Smoke tests pass on all three; file(1) confirms ARM aarch64 / ARM EABI5 / x86-64.

Also fixed a gap in the documented build deps: zlib1g-dev is required — without it the link fails with cannot find -lz. It was missing from the README alongside the -fcommon note.

Each tarball carries an INSTALL.txt with pick-your-arch, swap-and-restart, verify and rollback steps, plus RUNTIME-DEPS.txt. Operators replace the binary; their systemd unit and udev/USB group setup are untouched.

Note: ubuntu-24.04-arm is free because this repo is public. If it is ever made private, that job starts billing — the fallback is one matrix line to build arm64 under QEMU like armhf.

Refs

  • HathorNetwork/ops-tools#1415 — device-difficulty floor; this closes its "confirm on the rig" item and corrects the root-cause detail for BM1397
  • HathorNetwork/ops-tools#1426 — production fleet analysis, where this was found

set_ticket() was only ever called with 0.0, meaning "set the highest
valid ticket the chip supports" - diff 16 on a GSF/Compac F. The call
sites justify this with "pool will be above this anyway", which is true
for Bitcoin pools and false for Hathor tx mining: tx jobs arrive at
weight 17-32, i.e. difficulties far below 1.

A chip at ticket 16 will not report any nonce below weight 36, so it can
never answer a tx job at all, regardless of how fast it hashes.

Measured against production tx-mining-service over 48h (133 txs): a
75.8 GH/s Compac F at ticket 16 needs 16 * 2^32 / 75.8e9 = ~906 ms to
report anything, against a median tx lifetime of 54 ms, and solved 0
txs. A 40 GH/s NewPac - forced to ticket 1 by the BM1387 branch of the
hashrate formula - answers in ~107 ms and wins the txs the Compac F
cannot. At ticket 1 the Compac F would report in ~57 ms, making it the
fastest device in the fleet.

Replace the 0.0 arguments with an explicit HTR_TICKET_DIFF constant,
including in the ticket-validation retry and give-up paths, which would
otherwise silently revert the device to the maximum ticket after a
single validation hiccup. Note that MAX_TICKET_CHECK is already
documented as "checks allowed before forced to diff=1" and one give-up
branch still logs "just set it to 1.0", so diff 1 appears to have been
the original intent.

The diff-1 row of ticket_1397[] validates cleanly: hi_limit 0.0 means no
spurious "ticket too low" failures, and low_limit 1.9 is reached well
within the 50-nonce count.

Refs: HathorNetwork/ops-tools#1415, HathorNetwork/ops-tools#1426
@luislhl luislhl self-assigned this Jul 27, 2026
@luislhl luislhl moved this from Todo to In Progress (WIP) in Hathor Network Jul 27, 2026
Replaces the hardcoded HTR_TICKET_DIFF constant from the previous commit
with a real cgminer option, so this fork stays usable against Bitcoin
pools and the Hathor default is explicit rather than baked in.

--gekko-ticket-diff <1-64>, default 1, or 0 for the chip's highest valid
ticket. The value is passed straight through to set_ticket(), so 0
reproduces the stock upstream behaviour exactly.

The setter rejects values in (0,1): set_ticket() floors its argument and
the lowest ticket_1397[] entry is diff 1, so 0.5 would match no entry,
fall through the loop, and silently leave the ticket unset.

Documents the option and its Hathor rationale in README, along with the
-fcommon requirement on GCC 10+ (the unmodified upstream tree fails to
link without it too, so this is not specific to the fork).

Verified in a clean Ubuntu 22.04 container:
  - builds with ./autogen.sh --enable-gekko CFLAGS="-O2 -fcommon"
  - --help lists the option with default 1.0
  - 0, 1, 16, 64 accepted; 0.5, 100 and -1 rejected with the range message

Refs: HathorNetwork/ops-tools#1415, HathorNetwork/ops-tools#1426

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTPQE8KnWcyAvBwbDeBufb
@luislhl luislhl changed the title gekko: request ticket diff 1 so Hathor tx jobs can be answered gekko: add --gekko-ticket-diff so Hathor tx jobs can be answered Jul 27, 2026
@luislhl

luislhl commented Jul 28, 2026

Copy link
Copy Markdown
Author

Review: fix/gekko-ticket-diff-for-hathor-tx-mining

Verdict: request changes. The ticket-mask analysis in the PR description is essentially correct and I verified the load-bearing parts of it against the code. But the branch introduces a deterministic SIGSEGV on config save that fires with the default settings, on every gekko build, whether or not anyone passes the new flag. I reproduced it, bisected it to this branch, and validated a one-line fix. There are also two behaviour-change consequences of default = 1 that the PR does not mention and that will bite non-Hathor (and large-rig Hathor) users.

9 findings: 1 P0, 2 P1, 3 P2, 3 P3. Plus 6 things I checked and found correct, and one wrong claim in the README that is worth fixing because it is the stated justification for the whole change.

Build verification: ubuntu:22.04 + ./autogen.sh --enable-gekko CFLAGS="-O2 -fcommon". Also built with --enable-icarus (gekko off) to check the ifdef guards — that build succeeds.


P0

1. --gekko-ticket-diff makes "save config" segfault, with the default value, in every gekko build

(confidence: 10/10 — reproduced, bisected, fix validated)cgminer.c:6057-6062

write_config() decides an option is a float by comparing the callback pointer against a hardcoded list:

			if (opt->type & OPT_HASARG &&
			    ((void *)opt->cb_arg == (void *)set_float_0_to_500 ||
			     (void *)opt->cb_arg == (void *)set_float_125_to_500 ||
			     (void *)opt->cb_arg == (void *)set_float_100_to_250)) {
				fprintf(fcfg, ",\n\"%s\" : \"%.1f\"", p+2, *(float *)opt->u.arg);
				continue;
			}

set_float_ticket_diff is not in that list, so --gekko-ticket-diff falls through to the generic string branch immediately below (cgminer.c:6065-6070):

				char *carg = *(char **)opt->u.arg;

				if (carg)
					fprintf(fcfg, ",\n\"%s\" : \"%s\"", p+2, json_escape(carg));

opt->u.arg is &opt_gekko_ticket_diff, a 4-byte float. This does an 8-byte out-of-bounds read of a 4-byte object, reinterprets the IEEE bits as a char *, and hands it to json_escape()strlen() (cgminer.c:5965). The default 1.0f = 0x3F800000 is non-NULL, so the NULL guard does not save it.

Every pre-existing float option in the tree is in that list. This PR adds the first one that is not.

Reproduction (save via the API; also reachable from the curses "Write config file" menu at cgminer.c:6549, and api.c:3228):

# this branch, no flag passed at all — default 1.0
$ ./cgminer --benchmark --api-listen --api-allow W:0/0 -T &
$ echo -n 'save|/tmp/cfg.conf' | nc 127.0.0.1 4028
Segmentation fault (core dumped)
RESULT: EXITED code=139
/tmp/cfg.conf -> 0 bytes

# same test, origin/master
RESULT: MASTER SURVIVED
/tmp/cfg.conf -> 1005 bytes

# this branch, explicit values
ticket-diff=0  : EXITED code=139
ticket-diff=1  : EXITED code=139
ticket-diff=16 : EXITED code=139

Fix (validated — patched build survives and emits "gekko-ticket-diff" : "1.0", 1034-byte config): add the callback to the float list at cgminer.c:6057.

			if (opt->type & OPT_HASARG &&
			    ((void *)opt->cb_arg == (void *)set_float_0_to_500 ||
#ifdef USE_GEKKO
			     (void *)opt->cb_arg == (void *)set_float_ticket_diff ||
#endif
			     (void *)opt->cb_arg == (void *)set_float_125_to_500 ||
			     (void *)opt->cb_arg == (void *)set_float_100_to_250)) {

The #ifdef USE_GEKKO is needed because write_config() is not itself gekko-guarded. As a side benefit it also removes the -Wunused-function warning in finding 9.


P1

2. Dropping info->difficulty to 1 tightens the no-nonce watchdog by 16-64x and can ratchet the clock down permanently

(confidence: 7/10 — code path verified; whether it fires depends on your pool's work continuity)driver-gekko.c:1870-1876, 3471-3474, 3552-3562

		// expected ms per nonce for PT_NONONCE
		info->nonce_expect = info->fullscan_ms * info->difficulty;
...
		// CDF >2000% is avg once in 485165205.1 nonces
		info->nonce_limit = info->nonce_expect * 20.0;

nonce_limit scales linearly with info->difficulty. For your measured device (75.8 GH/s Compac F, fullscan_ms ≈ 56.7): at ticket 16 nonce_limit18.1 s; at ticket 1 it becomes 1.13 s (floored to ~5 s in practice by the MS_SECOND_5 plateau-check cadence at driver-gekko.c:3451). On a 6-chip GSFM that upstream would run at ticket 64, it goes from ~2.3 s to ~36 ms.

The statistical margin is preserved (still 20x expectation), so this is fine while work is flowing. The problem is that info->last_nonce is refreshed only when a nonce actually arrives (:2378, :2633, :2898, :3049, :5833) — nothing refreshes it during a work drought, and the plateau block has no "do we have work?" guard, only ms_tdiff(&now, &info->last_reset) > MS_SECOND_10 (:3414). So a stratum reconnect or a tx-mining-service gap that previously had 18 s of slack now has ~5 s.

						if (has_freq && i == 0 && info->nonce_limit > 0.0
						&&  ms_tdiff(&now, &info->last_nonce) > info->nonce_limit)
						{
							plateau_type = PT_NONONCE;

PT_NONONCE unconditionally forces doreset = true (:3592-3595). And info->plateau_reset is zeroed only once per device in compac_detect_one (:5429), never on reset — so from the third event onward:

						if (info->plateau_reset >= 2) {
							if (ms_tdiff(&now, &info->last_frequency_adjust) > MS_MINUTE_30) {
								// Been running for 30 minutes, possible plateau
							} else {
								// Step back frequency
								info->frequency_fail_high -= info->freq_base;
							}
							new_frequency = limit_freq(info, FREQ_BASE(info->frequency_fail_high), true);
						}

each subsequent stall steps the requested frequency down one freq_base, and it never recovers. A flaky pool link now ratchets the miner to the frequency floor.

Suggested action: at minimum, document it. If you want a code fix, the cleanest is to floor nonce_limit at its diff-16-equivalent (or at a wall-clock minimum like 10 s) rather than let it track difficulty all the way down — the watchdog is meant to catch a dead chip, not a quiet pool.

3. The default bypasses the cclimit column, which exists specifically to keep big rigs off low tickets

(confidence: 8/10)driver-gekko.c:1103-1119, 1146-1148

// limit to max diff of 16 unless the chips x cores is a bit better than a GSF/GSFM
//  to ensure enough nonces are coming back to identify status changes/issues
...
	{ 64,	0xfc,  20000,	65.9,	63.9, 2600 },
	{ 32,	0xf8,  10000,	33.3,	31.9, 1300 },
		if (udiff >= ticket_1397[i].diff && cc > ticket_1397[i].cclimit)

With udiff = 1 the loop can only ever match the last row (cclimit 0), so the entire cclimit column becomes dead. A 6-chip GSFM (cc = 4032) or a BFCLAR (cores = 8154, driver-gekko.c:6068) goes from ticket 64 to ticket 1: 64x the nonce rate, 64x the USB reads, 64x the per-nonce rebuild_nonce() double-SHA256, and 64x the shares pushed at the pool (roughly 350-560 shares/sec on a 1.5-2.4 TH/s rig).

The evidence in the PR is one 75.8 GH/s single-chip Compac F. The default is global with no per-ident or per-cc scoping. The README's "set it to 0 for Bitcoin" escape hatch does not cover a large Hathor rig, which is exactly the case cclimit was protecting against — and that user has no way to get the intended behaviour except by picking a number by hand.

Question for you, not a prescription: is the right default 1, or 0 with Hathor deployments passing --gekko-ticket-diff 1 explicitly? The fork exists to mine Hathor, so 1 is defensible — but if you keep it, the README should say what happens on a multi-chip device, and you may want the ticket-1 default to apply only when cc is below cclimit-for-32.


P2

4. The give-up path no longer sets the chip max, and the failure it handles is newly reachable

(confidence: 7/10)driver-gekko.c:2299-2307 and 2341-2350 (identically at 2554/2596 and 2819/2861)

You asked specifically whether threading the option through the validation paths is correct. It does not create an infinite loop and it does not defeat MAX_TICKET_CHECK — see the "verified correct" section below. But there is a real change of meaning here:

						// give up - just set it to 1.0

						applog(LOG_ERR, "%d: %s %d - ticket %u failed too many times setting to max",
							compac->cgminer_id, compac->drv->name, compac->device_id, ticket_1397[i].diff);

						//set_ticket(compac, 1.0, true, true);
						set_ticket(compac, opt_gekko_ticket_diff, true, true);
						info->ticket_ok = true;

The "no low nonce after nonce_count" branch means the chip's real ticket is higher than what we asked for. Under upstream that meant "higher than the chip maximum" — effectively impossible, so the no-op fallback was harmless. With opt_gekko_ticket_diff = 1 the ordinary cause becomes the mundane one: the 0x51 .. BM1397TICKET write was dropped or ignored and the chip is still at its previous/default mask. The handler's response is to re-send the identical value that just failed four times, then assert ticket_ok = true.

Resulting state: info->difficulty == 1 while the chip is really masking at 16. That under-reports hashrate 16x via info->hashes += info->difficulty * 0xffffffffull (:2391) and makes nonce_limit 16x too tight, which feeds straight into finding 2 — a PT_NONONCE reset loop with the frequency ratchet.

Compounding it: set_ticket() does not reset info->ticket_failures (:1156-1165), and it is cleared only on a successful confirm (:2331). After a give-up, a later MINER_RESETcompac_send_chain_inactiveset_ticket restarts validation with ticket_failures still at 4, so the very first failure jumps straight to give-up with zero retries. (This is pre-existing upstream shape, but it only becomes consequential now that the failure is reachable.)

Suggested fix: the give-up path should pass 0.0 (chip max), matching what its own log message and comment already claim. Only the retry paths (:2316, :2359, and siblings) should use opt_gekko_ticket_diff.

5. nan passes the range validator and produces a silently dead device

(confidence: 9/10 — verified empirically)cgminer.c:1372-1373

	if (*i != 0 && (*i < 1 || *i > 64))
		return "Value out of range - use 0 for the chip maximum, or 1 to 64";

opt_set_floatval uses strtof, which accepts "nan". All three NaN comparisons are false, so it passes. I tested every boundary — 0.5, 0.999, 65, -1, inf, abc, "" are all correctly rejected; only nan slips through:

0.5   => Value out of range - use 0 for the chip maximum, or 1 to 64
0.999 => Value out of range ...
65    => Value out of range ...
-1    => Value out of range ...
inf   => Value out of range ...
abc   => 'abc' is not a number
nan   => (accepted, cgminer starts)

Downstream, diff == 0.0 is false so the diff = 128 branch is skipped, and udiff = (uint32_t)floor(NaN) is UB (lands on 0 on x86-64/gcc). No row matches, so set_ticket hits if (!got) return; (:1173-1175) — no ticket command is sent and nothing is logged. info is cgcalloc'd, so info->difficulty stays 0 (device reports 0 GH/s forever) and noncepercent() indexes ticket_1397[0] = diff 64 (:1215).

Fix: invert the test so NaN falls into the reject branch:

	if (!(*i == 0 || (*i >= 1 && *i <= 64)))
		return "Value out of range - use 0 for the chip maximum, or 1 to 64";

6. The stated justification for the change is factually wrong in both the README and the code comment

(confidence: 9/10)README:31, driver-gekko.c:1085

// 36 and can never answer any tx job at all, however fast it hashes.

This is contradicted by the very next paragraph, which gives the formula 16 * 2^32 / 75.8e9 = ~906 ms. Hashrate is exactly the variable that determines whether the chip answers in time — a ~1.3 TH/s device at ticket 16 would report in ~54 ms and would win jobs fine.

The mechanism is also stated backwards. A ticket-16 nonce is a harder solution than a weight-17 job requires, and a harder solution always satisfies an easier target. The chip at ticket 16 can answer a weight-17 tx job; it just takes 906 ms to produce a needlessly-hard answer, and the job is gone by then. The problem is latency, not impossibility. Your 0-of-133-over-48h measurement is consistent with either framing, so the conclusion stands — but the reasoning as written would lead someone to conclude that a faster ASIC wouldn't help, which is the opposite of the truth.

Worth adding the residual honestly too: ticket 1 is the hardware floor (mask 0x00; there is no lower row and the BM1397 baseline is diff 1). At 57 ms expected report time against a 54 ms median job lifetime, a single Compac F still loses roughly half the races. This takes you from "never" to "about half the time", not to "solved".


P3

7. README's BM1384 claim is wrong for the 2Pac and for any overclocked BM1384

(confidence: 8/10)README:40

BM1384 (2Pac, Terminus) and BM1387 (NewPac) chips do not use this setting; their ticket is derived from hashrate and already lands at 1 in practice.

The derivation is driver-gekko.c:1825-1827:

		info->ticket_mask = bound(pow(2, ceil(log(info->hashrate / (2.0 * 0xffffffffull)) / log(2))) - 1, 0, 4000);
		info->ticket_mask = (info->asic_type == BM1387) ? 0 : info->ticket_mask;
		info->difficulty = info->ticket_mask + 1;

BM1384 is cores = 55 (:6026). Working through the defaults:

device chips default freq hashrate resulting difficulty
Compac / Terminus (GSC/GSE, 1 chip) 1 150 MHz 8.25 GH/s 1
2Pac (GSD, 2 chips) 2 100 MHz 11.0 GH/s 2
2Pac tuned 2 200 MHz 22.0 GH/s 4

The claim holds for BM1387 (hardcoded to 0) and for single-chip BM1384 at stock clocks. It does not hold for the 2Pac, and any BM1384 above ~156 MHz per chip crosses the threshold. A tuned 2Pac sits at difficulty 4 = weight 34, which is the same class of problem this PR fixes for the BM1397 — and there is no knob for it. The README currently tells those users they're fine.

8. Help text and comments promise a range the code does not honour; the applied value is invisible

(confidence: 9/10)cgminer.c:2076, driver-gekko.c:2301/2343, driver-gekko.c:1059

  • cgminer.c:2076 says "ticket difficulty 1-64", but the reachable set is {1,2,4,8,16,32,64}, further clamped by cclimit. --gekko-ticket-diff 40 silently yields 32; --gekko-ticket-diff 64 on a Compac F (cc = 672) silently yields 16. I confirmed 3 is accepted and floors to 2. There is no API field exposing the requested value — :6395 exposes only the resulting info->difficulty.
  • driver-gekko.c:2301 // give up - just set it to max and :2343 // give up - just set it to 1.0 now both describe the same call, and neither matches what it does. The applog text "failed too many times setting to max" (:2303, :2345, and siblings) will actively mislead whoever debugs finding 4 on real hardware. Either fix the call per finding 4 or fix the strings.
  • driver-gekko.c:1059: // with the lowest nonce_count of 150 below for diff 2, TICKET_BLOW_LIM 4 will always be exceeded if incorrectly set to diff 1 — the lowest nonce_count in the table is now 50 (the diff-1 row), because that row was unreachable upstream and is now the default. The reasoning in this comment no longer describes the code.
  • driver-gekko.c:1173-1175: if (!got) return; is the sole failure mode for finding 5 and for any cc == 0 case, and it logs nothing. An applog(LOG_ERR, ...) there would make it self-diagnosing.

9. -Wunused-function in non-gekko builds

(confidence: 10/10 — observed in the build)cgminer.c:1362

cgminer.c:1362:14: warning: 'set_float_ticket_diff' defined but not used [-Wunused-function]

The build itself succeeds with gekko disabled — the ifdef guards are correct (see below). But set_float_ticket_diff is defined outside #ifdef USE_GEKKO while its only caller is inside it. Note this is not the same as set_float_0_to_500, which does not warn because it also serves --flow-step-freq at cgminer.c:1978 (USE_FLOW). The P0 fix in finding 1 removes this warning as a side effect, since the write_config reference is a second use.


Verified correct

Things I checked because the PR description asserts them or because they looked risky, and which hold up:

  • The three duplicated validation blocks are byte-identical. I extracted driver-gekko.c:2280-2372, 2535-2627, 2800-2892 and diffed them pairwise: zero differences. No copy-paste divergence, all six give-up/retry sites got the same treatment.
  • The diff-1 row analysis in the PR is right. hi_limit is 0.0 and the enclosing guard is if (!info->ticket_ok && diff > 0) (:2282), so diff < ticket_1397[i].hi_limit is unreachable — no spurious "ticket too low" failures, as claimed. And P(share diff < 1.9) = 1 - 1/1.9 ≈ 47% per nonce, so ticket_got_low within the 50-nonce count is a near-certainty ((1/1.9)^50 ≈ 1.5e-12, consistent with the row's Erlang=1.5x10-7 annotation). Side effect: below_nonces is now permanently 0, so the API's TicketBelow field (:6398) is dead — cosmetic only.
  • No unbounded retry loop, and MAX_TICKET_CHECK still works. set_ticket() resets ticket_work, ticket_nonces, below_nonces, ticket_ok, ticket_got_low — but deliberately not ticket_failures (:1156-1165), which is monotonic between confirms and cleared only at :2331. Both branches still gate on ticket_failures > MAX_TICKET_CHECK, and the give-up sets ticket_ok = true after set_ticket() clears it, so give-up is terminal. Upstream's retry also re-sent the same value (init and retry both passed 0.0), so the retry structure is genuinely unchanged — only the consequence differs, per finding 4.
  • Locking is correct at all 14 sites. The two init sites (:1542, :1608) are reached from compac_scanwork with no info->lock held → locked=false is right; all twelve validation sites sit inside the info->lock region → locked=true is right. set_ticket's callees use different mutexes (gekko_usleepslock, gh_offsetghlock, job_offsetjoblock, compac_send2→none), so no self-deadlock, and the PR doesn't increase the frequency of the pre-existing info->lock-held-across-a-20ms-sleep pattern.
  • The ifdef guards line up. float opt_gekko_ticket_diff = 1.0; at cgminer.c:355 is inside #ifdef USE_GEKKO (314-…); extern at miner.h:1098 is inside #ifdef USE_GEKKO (1067-…); the OPT_WITH_ARG entry at cgminer.c:2074 is inside #ifdef USE_GEKKO (1984-2083). ./autogen.sh --enable-icarus (gekko off) builds and links cleanly, one cosmetic warning aside (finding 9). The write_config fix in finding 1 is the one place that needs its own #ifdef.
  • The option is parsed before it's needed. opt_parse / load_default_config run long before usb_initialise and device detect, and nothing in the API path mutates opt_gekko_ticket_diff at runtime, so there's no mid-validation change hazard.
  • No software/hardware floor mismatch. I chased whether cgminer would reject the sub-diff-1 shares this is meant to enable. submit_noncetest_nonce (cgminer.c:8501, 8464) hard-requires the top 32 bits of the hash to be zero, i.e. Bitcoin diff ≥ 1, before submit_tested_work runs fulltest against the Hathor weight target from weight_to_target. That floor matches the chip's own mask-0x00 baseline exactly, so ticket 1 is coherent end to end. It also means ticket 1 is genuinely the lowest achievable — worth stating in the README (see finding 6).
  • Hashrate accounting stays unbiased when the ticket actually takes: nonce rate scales up exactly as info->difficulty scales down, so info->hashes, gh->diffsum and inc_hw_errors_n(thr, info->difficulty) keep the same expectation (variance improves). Only the mismatched case in finding 4 breaks it.
  • Range validation is otherwise solid(0,1), negatives, >64, inf and non-numeric input are all rejected with a clear message, tested empirically. Only nan gets through (finding 5).

Also confirmed for context: the diff-1 (and diff-2/4/8) rows of ticket_1397[] were unreachable in upstream cgminer — every live call site passed 0.0, and cc for a 1-chip BM1397 is 672, which always resolves to the diff-16 row. This PR makes a never-before-exercised table row the default path. That's not an objection, but it's worth knowing that "it validates cleanly" has no field history behind it, only the reasoning (which I checked and agree with).


Open questions

  1. Is 1 the right default, or should it be 0 with Hathor deployments opting in? Finding 3 is the crux: the escape hatch only helps someone who knows to reach for it, and it doesn't help a large Hathor rig at all. What's the deployment shape — single Compac Fs, or anything multi-chip?
  2. Has anything been run against a device where the ticket write is dropped? Finding 4's chain (ticket mismatch → 16x hashrate under-report → 16x-tight nonce_limit → PT_NONONCE reset → frequency ratchet) is the failure mode I'd most want to see excluded on hardware before this lands, and it's the one the PR description's confidence doesn't cover.
  3. Was config save ever exercised? Finding 1 suggests not — which is fair, since nothing else in this change touches it. Worth adding save to whatever manual smoke test you run, since it's a one-command check.

Out of scope, noticed in passing: gen_stratum_work reads pool->weight at cgminer.c:8076, but cg_runlock(&pool->data_lock) is released at :8067 — the read races the stratum thread's write at util.c:2347. Pre-existing (came in with #5), not this branch's problem, but flagging it since I was in there.

Reviewed with the /review workflow: full-diff read, targeted reading outside the diff (set_ticket, ticket_1397, the plateau/watchdog path, write_config, submit_nonce), an independent adversarial pass, and empirical verification in Docker (gekko and non-gekko builds, option-parsing matrix, crash reproduction, master bisect, fix validation). Nothing was committed, pushed, or left in the working tree.

write_config() decides how to serialise an option by comparing its
callback pointer against a hardcoded list of setters. set_float_ticket_diff
was not in the float list, so --gekko-ticket-diff fell through to the
generic string branch, which does *(char **)&opt_gekko_ticket_diff: an
8-byte read of a 4-byte float, punned to a pointer and handed to strlen().

This crashed on every config save, with default settings, on any gekko
build, whether or not the option was ever passed. Reproduced in a
container: the API "save" command exits 139 (SIGSEGV) having written 0
bytes; the same build with this fix saves 1040 bytes containing
"gekko-ticket-diff" : "1.0".

Also in this commit:

- Reject NaN in the setter. Every comparison against NaN is false, so it
  slipped past the range check and reached set_ticket(), where floor(NaN)
  matches no ticket_1397[] entry and would leave the device's ticket unset.

- Guard the setter with USE_GEKKO so builds without gekko support don't
  warn about an unused static function. Verified with an --enable-icarus
  build, which compiles clean.

All three found by review of PR #6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTPQE8KnWcyAvBwbDeBufb
@luislhl

luislhl commented Jul 28, 2026

Copy link
Copy Markdown
Author

Reviewed all 9 findings. The P0 is real — I reproduced it independently before fixing it, and it's a bug I could not have found by reading my own diff. Fixed in 27c01dd.

P0 — confirmed and fixed

write_config() matches options to serialisers by callback-pointer identity, so set_float_ticket_diff fell through the float branch into the string branch and strlen'd a punned float. My own repro on the pre-fix build, via the API save command:

Segmentation fault (core dumped)
PROCESS: DIED exit=139
config written: 0 bytes

Same test on the fixed build:

STATUS=S,...,Msg=Configuration saved to file '/tmp/out.conf'
PROCESS: alive (no crash)
config bytes: 1040
ticket line: "gekko-ticket-diff" : "1.0",

Worth stating plainly how bad this was: it fired on default settings, on every gekko build, whether or not anyone passed the flag — so it would have bricked config-save for every existing user of this fork, including the two miners who never wanted the new option. The finding is exactly right that pointer-identity registration is an invisible coupling; nothing in the option table hints that adding a setter obliges you to edit a function 4,500 lines away.

Also fixed in the same commit:

  • P2 (nan)nan passed the range validator because every comparison against NaN is false. Now rejected explicitly. Verified: --gekko-ticket-diff nanValue must be a number.
  • P3 (unused function) — setter is now #ifdef USE_GEKKO guarded, with the matching guard on the write_config entry as you correctly flagged. Verified an --enable-icarus (non-gekko) build compiles clean.

P1 (watchdog) — mechanism confirmed, but with a counterweight from production

I verified the arithmetic: nonce_expect = fullscan_ms * difficulty, nonce_limit = nonce_expect * 20, so on a 75.8 GH/s Compac F (fullscan_ms = 56.6) the window is 18.1 s at ticket 16 and 1.13 s at ticket 1. Your numbers are exact, and PT_NONONCE does drive the frequency-reset path.

Two things that temper it:

  1. The statistical false-positive rate is unchanged. The 20x multiplier is scale-invariant — it's 20x the expected inter-nonce interval at either ticket, so P(no nonce in window) ≈ e^-20 ≈ 2e-9 either way. The exposure isn't statistical, it's to non-statistical stalls (USB hiccups, work starvation, pool switching under load-balance) that 18 s absorbs and 1.1 s may not.
  2. A device in this fleet has been running at difficulty 1 for eight weeks. The BM1387 NewPac is forced to ticket_mask = 0 by driver-gekko.c:1801, giving it nonce_limit ≈ 20 x 107 ms ≈ 2.1 s — the same order as the post-patch Compac F. It has run since 2026-06-02 on load-balance across 3 pools and is the only cgminer in the fleet actually solving txs. That's real evidence a ~1-2 s window survives production, though not proof: the BM1397 path checks info->last_nonce while the BM1387 path checks asic->last_nonce, so they aren't the same code.

So I'm treating this as the specific thing to watch during hardware validation rather than a blocker: grep the trial logs for plateau_type PT_NONONCE. If it appears, the fix is to floor nonce_limit at an absolute minimum instead of letting it scale linearly to zero. Added to the PR checklist.

P1 (cclimit) and P2 (give-up path) — real, and they need a human decision

Both are genuine and I'm not resolving them unilaterally:

  • cclimit dead code. Right that a default of 1 makes the cclimit column unreachable, so a 6-chip GSFM or BFCLAR drops from ticket 64 to 1 — a 64x nonce/USB/share rate change for rigs nobody in this fleet owns. This is the blast-radius question. Options: make the default conditional on chip count, or keep it and document loudly.
  • Give-up path. Right that it now re-sends the value that just failed rather than the chip maximum, and that its log still says "setting to max". But the alternative is worse for the use case: a Hathor miner whose ticket validation fails three times would silently revert to ticket 16 and stop solving txs — precisely the bug this PR exists to fix, except now invisible. My inclination is to keep the configured value and fix the misleading message, but that's a real trade-off.

Verified-correct items

Thanks for chasing the test_nonce diff-1 floor against the chip's mask-0x00 baseline, and for diffing the three validation blocks pairwise — "byte-identical" is a much better answer than my "check all three got the same treatment". The note that the diff-1 row was unreachable in upstream and therefore has zero field history is the most useful line in the review; it reframes the hardware trial from "confirm my arithmetic" to "exercise a code path nobody has ever run."

Two README claims you flagged as wrong (BM1384 difficulty on the 2Pac, and "however fast it hashes") — both fair, fixing in the next push.

luislhl and others added 2 commits July 27, 2026 23:23
Both flagged in review of PR #6.

"can never answer a tx job, however fast it hashes" was wrong. A chip at
ticket 16 reports only nonces of weight >= 36, and such a nonce
over-satisfies a weight-17 tx - so it can answer one, just not before the
tx is gone. The argument is latency, not impossibility, and the doc
contradicted its own ticket * 2^32 / hashrate formula two lines later.

The BM1384 claim that its hashrate-derived ticket "already lands at 1 in
practice" only holds for slow sticks. bound(pow(2, ceil(log2(hashrate /
2^33))) - 1) gives difficulty 1 at ~5 GH/s, but 2 at ~10 GH/s and 4 at
~20 GH/s, so a well-tuned 2Pac carries a reporting floor of its own that
this option does not address.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VTPQE8KnWcyAvBwbDeBufb
The three operators running this fork are on Raspberry Pis, and asking each of
them to install a toolchain and build from source is the main friction in
getting the ticket-diff fix onto the hardware. Build the binaries centrally
instead and attach them to a GitHub release.

Adds a tag-triggered workflow that builds linux-arm64 (Pi OS 64-bit),
linux-armhf (Pi OS 32-bit) and linux-amd64. arm64 builds natively on GitHub's
ARM runners; armhf needs QEMU because those runners are Cobalt/Ampere cores
with no 32-bit support. One binary per architecture covers every GekkoScience
model: they share driver-gekko.c and are selected at runtime by USB id, and
the --gekko-*-detect allowlists all default to off.

The build runs on Debian Bullseye rather than the runner's own Ubuntu 24.04.
glibc is forward- but not backward-compatible, so a binary linked against
glibc 2.39 would fail to start on Pi OS Bookworm. Bullseye also lets us skip
libjansson-dev, which makes configure fall back to the bundled copy and link
it statically - one less runtime dependency on the miner host. The shipped
binary needs only libcurl4, libusb-1.0-0 and libncurses6.

Every binary is smoke-tested before it ships. Besides --version and option
validation, the tests drive a config save through the API and assert the
process survives it. That is a regression test for the SIGSEGV found in review
on this branch: write_config() dispatches on the option's callback pointer, so
an unregistered setter gets type-punned into strlen(). It crashed on default
settings regardless of whether the new flag was used, and no amount of reading
the diff would have caught it.

Also records zlib1g-dev as a build dependency - without it the link fails with
"cannot find -lz". It was missing from the README alongside the -fcommon note.

Verified locally for all three architectures under QEMU: smoke tests pass,
file(1) confirms aarch64 / ARM EABI5 / x86-64, and the glibc floor is 2.29.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress (WIP)

Development

Successfully merging this pull request may close these issues.

1 participant