Skip to content

farm tier: give serialising clients a real extranonce at subscribe - #783

Merged
defenwycke merged 4 commits into
mainfrom
fix/farm-tier-subscribe-extranonce
Aug 28, 2026
Merged

farm tier: give serialising clients a real extranonce at subscribe#783
defenwycke merged 4 commits into
mainfrom
fix/farm-tier-subscribe-extranonce

Conversation

@defenwycke

Copy link
Copy Markdown
Contributor

Two Braiins Hashpower contracts were left running overnight on 2026-08-27 and both paused.
The pool delivered zero shares: shares and shares_archive are both 0 for %loadtest%,
and have never held a Braiins share on vm1.

What was wrong

A serialising SV1 client — proxies and rented-hashrate marketplaces — waits for the
mining.subscribe RESPONSE before it will authorise. The channel opens on
mining.authorize, so those clients deadlock, and a 1.5 s fallback answers subscribe with an
8-byte all-zero placeholder extranonce while the real prefix is 12 bytes.

That placeholder is not approximate, it is unusable. The coinbase declares a 91-byte scriptSig
ending in OP_PUSHBYTES_20, reserving exactly 20 bytes:

real:        en1 12 + en2 8 = 20  ✅
placeholder: en1  8 + en2 8 = 16  ❌  4 bytes short

so the client builds a coinbase shorter than its own length byte — malformed transaction, wrong
merkle root, every share invalid. mining.set_extranonce cannot rescue it: that is an
OPTIONAL extension and Braiins never sends mining.extranonce.subscribe (0 occurrences in 24 h
of fleet logs, 0 on the wire).

Reproduced on a live node with this repo's own tool, which already carries the acceptance test
for it as an informational probe:

[411-rekey]  NOT MET — subscribe answered in 1.53s,
             extranonce1=placeholder, set_extranonce re-key: yes

What this does

Behind open_channel_on_subscribe, the channel opens at subscribe under
PROVISIONAL_CHANNEL_IDENTITY, so the subscribe response carries the real prefix first time.
The address then cannot live in the channel identity, so it travels per share in the worker
TLV, which carries the full <address>.<worker> in that mode; the pool uses it verbatim and
fails closed if it is missing rather than guessing.

  • The discriminator is the channel identity, never "the TLV looks dotted". A worker name
    may legitimately contain a dot (addr.farm1.rig1 yields farm1.rig1, pool: build_webhook_user_identity splits on the first dot, extract_worker_name on the last — multi-dot workers collapse #481), so keying on
    that would silently reassign farm1 as a payout address. There is a test for that shape.
  • tlv_compatible_username refuses rather than truncates. A cut identity is not a shorter
    identity: it mangles the payout address, or collapses two workers sharing a prefix onto one
    miner_id. Its private 32-byte cap was a third copy of a limit owned by
    extensions_sv2::MAX_USER_IDENTITY_LENGTH; two copies of that constant drifting is what sent
    shares out with no TLV at all and misattributed ~395 of them under translator: open the channel on subscribe, and carry the full identity in the TLV #447. It now defers to the
    single owner, and PROVISIONAL_CHANNEL_IDENTITY gets one owner from the start for the same
    reason.
  • channel_open_requested guards the open. channel_id cannot: it is set only when
    OpenExtendedMiningChannelSuccess returns, so a pipelining miner's authorize lands mid-flight
    and would burn a second upstream channel.

The open is debounced, and that is not a detail

The pool's extended allocator is server_id || counter with a two-byte server id, leaving a
16-bit counter — 65,535 prefixes per process, never handed back (#746). Measured on vm1:
sri-pool up 28.5 h, counter at 0x1be3 = 7,139, so ~250/h and roughly ten days of uptime
before every channel open starts failing
, with the pool still green and
OpenMiningChannelError the only tell.

Opening on every subscribe would have been worse than a capacity problem. #746 deliberately
allocates the prefix after validation so an unauthenticated client cannot burn the
space — and a subscribe arrives before any authorize, so opening on it hands that capability
straight back to any drive-by prober.

SUBSCRIBE_OPEN_DEBOUNCE is 300 ms, sized from measured client behaviour: a pipelining miner
authorises ~12 ms after subscribe and a marketplace capability probe disconnects ~10-30 ms in.
Neither reaches the timer, so the pipelining path is byte-identical to today and probes cost
nothing. Only a client still holding the connection open opens early.

Why #447 is no longer blocked

#447 did this and was reverted (#456) pending #455. Both blockers are resolved on main:

Rollout

pool_sv2 leads, on every node, before the flag is turned on anywhere. Rolled the other
way round the pool splices a worker onto the sentinel and credits the address portion — sri
to nobody. That is the #447 failure mode exactly.

The flag ships off, so both binaries are safe to roll at any time; flipping it is a separate,
reversible step. Remove the flag once the fleet is on it.

Canary: ghost-vm6, both binaries, 2026-08-28

Deployed via scripts/deploy-node.sh --canary (pool_sv2 first), which ran its own smoke and
started the soak clock. Backups pool_sv2.bak.20260828-122244,
translator_sv2.bak.20260828-122429.

Flag OFF — all 10 gating cases pass and [411-rekey] still NOT MET, byte-for-byte the
pre-deploy baseline. No regression for anything that works today.

Flag ON — all 10 still pass, and the acceptance probe flips:

[serializer]  PASS — subscribe answered in 0.39s   (was 1.54s)
[411-rekey]   MET — extranonce1=000100360000000000000001, set_extranonce re-key: no

The placeholder path is gone rather than merely avoided, measured across the restart:

subscribe-response timeout   BEFORE: 97 in 30m      AFTER: 0

Farm port :4444, serialising client, fixed node against an unchanged one:

vm6 (this branch) vm1 (control)
subscribe reply 450 ms 1545 ms
extranonce1 12 B, real 8 B, all-zero placeholder
re-key none changed mid-session

Channel identities since the flip show the debounce doing its job — real Braiins traffic
pipelines and keeps its own identity, only genuine serialising clients go provisional:

20  bc1q7z…loadtest          (Braiins — authorize wins, identity unchanged)
 9  sri/donate/provisional   (serialising probes)

0 errors, 0 panics, 0 attribution failures; all units active.

What the canary does NOT prove

vm6 carries no miners, so no share was credited end-to-end. attribution_probe.py cannot
reach production difficulty (#464), so verify_attribution.sh against real traffic is the
check that counts before this goes near a node with miners.

I also have not proven this is specifically why the two contracts paused. What is measured:
Braiins never opts into set_extranonce, ~24% of connections fleet-wide were being answered
with an unmineable placeholder, and zero Braiins shares have ever been credited. The causal
chain is strong; the last link is inferred.

Not in this PR

The 16-bit prefix ceiling is real and pre-existing. Widening the server-id handling or
reclaiming prefixes on CloseChannel would remove the ten-day limit. Flagged, not fixed.

A serialising SV1 client — proxies and rented-hashrate marketplaces — waits for the
`mining.subscribe` RESPONSE before it will authorise. That response has to carry the
real, channel-allocated extranonce, and only the pool can mint one, so the channel
must open before `mining.authorize` arrives and its identity cannot hold the miner's
address. The address then has to travel per share in the Worker-Specific Hashrate
Tracking TLV.

This is the pool half of that, and it ships first: a translator that opens channels
provisionally is only safe against a pool that already understands them. Rolled the
other way round, `build_webhook_user_identity` would splice a worker onto the
sentinel and credit the address portion — `sri` — to nobody, which is how #447
misattributed ~395 shares in July.

- `PROVISIONAL_CHANNEL_IDENTITY = "sri/donate/provisional"`. It has to be a shape
  `PayoutMode::try_from` already parses or the channel open is rejected outright, and
  three segments so it cannot collide with a miner authorising as plain `sri/donate`.
  Pinned by a test.
- `build_webhook_user_identity` returns `Option<String>` and decides from the CHANNEL
  identity, never by inspecting the TLV. A worker name may legitimately contain a dot
  (`addr.farm1.rig1` yields `farm1.rig1`, #481), so "the TLV looks dotted" does not
  imply it holds an address — keying on that would silently reassign `farm1` as a
  payout target. There is a test for exactly that shape.
- A provisional channel whose TLV is absent, empty, or a bare worker resolves to
  `None` and the share is not credited. Splicing would have produced
  `sri/donate/provisional.rig1` — an address of `sri` — which looks entirely normal
  in the logs while paying nobody. `attributable` now folds in that resolution.
- The block path stays deliberately ungated: a block is always reported, falling back
  to the raw channel identity so the record still points somewhere traceable.

Behaviour for every channel opened on `mining.authorize` is unchanged — the address
still comes from the channel and the worker from the TLV.

Claude-Session: https://claude.ai/code/session_01ResUgwsjEwGZ99vZn4hu4R
…s a real extranonce

A serialising SV1 client waits for the `mining.subscribe` RESPONSE before it will
authorise. The channel opens on authorize, so those clients deadlock, and the 1.5s
fallback answers subscribe with an 8-byte all-zero PLACEHOLDER extranonce while the
real prefix is 12 bytes.

That placeholder is not merely approximate, it is unusable. The coinbase declares a
91-byte scriptSig ending in `OP_PUSHBYTES_20`, reserving exactly 20 bytes:

    real:        en1 12 + en2 8 = 20  ✅
    placeholder: en1  8 + en2 8 = 16  ❌  4 bytes short

so the client builds a coinbase shorter than its own length byte — malformed
transaction, wrong merkle root, every share invalid. `mining.set_extranonce` cannot
rescue it: that is an OPTIONAL extension, and these clients never send
`mining.extranonce.subscribe` (0 occurrences in 24h of fleet logs, 0 on the wire).

Behind `open_channel_on_subscribe`, the channel opens at subscribe under
`PROVISIONAL_CHANNEL_IDENTITY`, so the subscribe response carries the real prefix
first time, for pipelining and serialising miners alike.

The address then cannot live in the channel identity, so it travels per share in the
worker TLV, which now carries the full `<address>.<worker>` in that mode. The pool
half (562225d) uses it verbatim and fails closed if it is missing.

- `tlv_compatible_username` REFUSES rather than truncates. A cut identity is not a
  shorter identity: it mangles the payout address, or collapses two workers sharing a
  prefix onto one `miner_id`. Its private 32-byte cap was a third copy of a limit owned
  by `extensions_sv2::MAX_USER_IDENTITY_LENGTH`; two copies of that constant drifting
  is what sent shares out with no TLV at all and misattributed ~395 of them under #447.
  It now defers to the single owner.
- `PROVISIONAL_CHANNEL_IDENTITY` lives in `extensions_sv2` alongside the extension it
  belongs to, imported by both binaries. Duplicating it is the same failure mode as the
  length constant, so it gets one owner from the start.
- `channel_open_requested` guards the open. `channel_id` cannot: it is set only when
  `OpenExtendedMiningChannelSuccess` returns, so a pipelining miner's authorize lands
  mid-flight and would burn a second upstream channel.

⛔ ROLLOUT GATE, default OFF, so this binary is safe to roll at any time. `pool_sv2`
must understand provisional channels on EVERY node before it is switched on anywhere;
rolled the other way round the pool credits `sri` — nobody. Roll `pool_sv2` fleet-wide,
then flip the flag. Remove the flag once the fleet is on it.

The blockers that shelved #447 are both resolved on `main`: the duplicated
`MAX_USER_IDENTITY_LENGTH` (#422) and `set_difficulty` never reaching the wire (#455,
fixed by #575).

Tests: 62 translator, 35 pool, clippy clean. Not yet exercised end-to-end against a
live pool — the handshake needs a canary with the flag on.

Claude-Session: https://claude.ai/code/session_01ResUgwsjEwGZ99vZn4hu4R
…tranonce prefixes

Opening the channel on every `mining.subscribe` was wrong in a way the pool already
had a comment about.

The pool's extended allocator is `server_id || counter` with a two-byte server id,
leaving a 16-BIT counter — 65,535 prefixes for the lifetime of the process, never
handed back (#746). Measured on vm1: `sri-pool` up 28.5h, counter at `0x1be3` = 7,139,
so ~250/h and roughly ten days of uptime before every channel open starts failing.

Two problems with opening unconditionally:

- Subscribe-only probes (~15% of connections) would each burn a prefix despite never
  mining, shortening that window for no benefit.
- #746 deliberately moved allocation AFTER validation so an UNAUTHENTICATED client
  cannot burn the space. A subscribe arrives before any authorize, so opening on it
  immediately hands that capability straight back — a drive-by prober could exhaust
  the pool for every honest miner. That is a security regression, not a capacity one.

So the open is debounced by `SUBSCRIBE_OPEN_DEBOUNCE` (300ms), sized from measured
client behaviour rather than guessed: a pipelining miner's authorize follows its
subscribe by ~12ms and a marketplace capability probe disconnects ~10-30ms in. Neither
reaches the timer, so the pipelining path is byte-identical to today and probes cost
nothing. Only a client still holding the connection open — the serialising shape this
exists to serve — opens early, and it waits the channel-open round trip (~50-300ms)
instead of the old 1500ms, for a real extranonce instead of an unusable one.

Tests: 62 translator, 35 pool, clippy clean.

Claude-Session: https://claude.ai/code/session_01ResUgwsjEwGZ99vZn4hu4R
@defenwycke
defenwycke merged commit bad5e6f into main Aug 28, 2026
12 checks passed
defenwycke added a commit that referenced this pull request Aug 28, 2026
Caught on the ghost-vm5 canary within four minutes of turning
`open_channel_on_subscribe` on: the real miner was credited as

    bc1q7zvdh3…y492.bc1q7zvdh3…y492.bitaxe3

— the address spliced onto itself, a phantom `miner_id` that looks entirely
legitimate. 16 shares over 3m44s, all on vm5, flag now reverted.

WHAT I GOT WRONG

The TLV carried the full `<address>.<worker>` whenever the config flag was on. That
was right for the design as first written, where every channel opened on subscribe.
It stopped being right when the open was debounced (#783): a pipelining miner now
still opens on `mining.authorize` with its OWN `<address>.<worker>` as the channel
identity, so the pool spliced a full identity onto a channel that already carried the
address.

Whether a channel is provisional is a property of the CONNECTION, not of the config.
`channel_opened_provisionally` records it where it is decided — the debounce path that
actually claims the open — and the TLV is keyed on that. A pipeliner authorises ~12ms
in, long before the 300ms timer, so it reads false and sends the worker segment alone,
exactly as before this feature existed.

The payout address survived: `parse_user_identity` splits on the FIRST dot, so the
address resolved correctly throughout and no money moved. The worker did not, which
would have split one miner's stats across two ids.

DEFENCE IN DEPTH

`build_webhook_user_identity` now refuses to splice an address onto itself. The
invariant is that a full TLV only ever accompanies a provisional channel, but a
translator that breaks it should not be able to mint phantoms — which matters during a
mixed-version window, where the pool cannot assume anything about the translator in
front of it. A worker is never legitimately named after the address it sits behind, and
a merely-similar prefix (`bc1qAAAB` behind `bc1qAAA`) is still spliced; both pinned by
test.

⚠ This is the second time a rule that reads correctly in isolation was wrong because it
was keyed on the wrong scope. Prefer "what did this connection actually do" over "what
is this node configured to do" wherever the two can disagree.

Tests: 36 pool, 62 translator, clippy clean.

Claude-Session: https://claude.ai/code/session_01ResUgwsjEwGZ99vZn4hu4R
defenwycke added a commit that referenced this pull request Aug 28, 2026
…#785)

Caught on the ghost-vm5 canary within four minutes of turning
`open_channel_on_subscribe` on: the real miner was credited as

    bc1q7zvdh3…y492.bc1q7zvdh3…y492.bitaxe3

— the address spliced onto itself, a phantom `miner_id` that looks entirely
legitimate. 16 shares over 3m44s, all on vm5, flag now reverted.

WHAT I GOT WRONG

The TLV carried the full `<address>.<worker>` whenever the config flag was on. That
was right for the design as first written, where every channel opened on subscribe.
It stopped being right when the open was debounced (#783): a pipelining miner now
still opens on `mining.authorize` with its OWN `<address>.<worker>` as the channel
identity, so the pool spliced a full identity onto a channel that already carried the
address.

Whether a channel is provisional is a property of the CONNECTION, not of the config.
`channel_opened_provisionally` records it where it is decided — the debounce path that
actually claims the open — and the TLV is keyed on that. A pipeliner authorises ~12ms
in, long before the 300ms timer, so it reads false and sends the worker segment alone,
exactly as before this feature existed.

The payout address survived: `parse_user_identity` splits on the FIRST dot, so the
address resolved correctly throughout and no money moved. The worker did not, which
would have split one miner's stats across two ids.

DEFENCE IN DEPTH

`build_webhook_user_identity` now refuses to splice an address onto itself. The
invariant is that a full TLV only ever accompanies a provisional channel, but a
translator that breaks it should not be able to mint phantoms — which matters during a
mixed-version window, where the pool cannot assume anything about the translator in
front of it. A worker is never legitimately named after the address it sits behind, and
a merely-similar prefix (`bc1qAAAB` behind `bc1qAAA`) is still spliced; both pinned by
test.

⚠ This is the second time a rule that reads correctly in isolation was wrong because it
was keyed on the wrong scope. Prefer "what did this connection actually do" over "what
is this node configured to do" wherever the two can disagree.

Tests: 36 pool, 62 translator, clippy clean.

Claude-Session: https://claude.ai/code/session_01ResUgwsjEwGZ99vZn4hu4R
defenwycke added a commit that referenced this pull request Aug 29, 2026
…se could catch

`fd74982f4` changed behaviour that nothing asserted. The suite already had a probe for
the re-key — `[411-rekey]` — but it drives the *serializer* shape (subscribe, wait,
authorize), where the subscribe response lands before authorise arrives and the post-hoc
path never runs. It therefore reports MET on a node where the defect is live, which is
how this survived the #783/#784/#785 round.

The new case drives the shape the tcpdump actually captured: no `mining.configure` at
all, then `mining.subscribe` and `mining.authorize` pipelined into ONE TCP segment.
Measured against the live fleet on vm1 today, before the fix:

    [unneg-extranonce] FAIL — set_extranonce received: YES — never negotiated
    [411-rekey]        MET  (informational)

So the gate can fail, and it fails on exactly the case the old probe passes. It asserts
absence over a 4 s window; the offending notification fires ~1.2 ms in, so the window is
evidence rather than haste.

⚠ Still open: this proves the protocol violation is gone, NOT that it was why Braiins
disconnects. A synthetic client held the socket for 8 s across the defect without
closing, so the disconnect cause remains unproven.
defenwycke added a commit that referenced this pull request Aug 29, 2026
…ked for it (#786)

* fix(translator): only send `mining.set_extranonce` to clients that asked for it

`mining.set_extranonce` is an opt-in notification — a client accepts it only if it
requested `subscribe-extranonce` in `mining.configure`. The farm-tier path sent it
unconditionally, and Braiins' hashrate proxy closes the connection on receipt.

Observed on vm1, 2026-08-28, via tcpdump. Braiins subscribes and authorises in one
TCP segment, receives a structurally valid job, then sends a clean FIN ~13 ms later:

    IN   {"id":1,"method":"mining.subscribe","params":[]}
         {"id":2,"method":"mining.authorize","params":["bc1q…loadtest","x"]}
    OUT  {"id":2,…,"result":true}
    OUT  {"method":"mining.set_extranonce",…}      <- never negotiated
    OUT  {"id":1,…,"result":[…,"01000d48…",8]}     <- real extranonce anyway
    IN   [F.]

That connection sends no `mining.configure` at all, so it never opted in. 12,929
farm-port connections in 24 h on one node, median session 0.107 s.

The guard could not have worked. `already_subscribed` was inferred from the
extranonce still being the `DownstreamData::new` default of 8 zero bytes, which is
equally true of "subscribe answered with a placeholder" and "subscribe not answered
yet". In the defer-open path the subscribe response is still pending and carries the
real extranonce, so the notification is redundant as well as unnegotiated.

`Configure::extensions` is private and only the version-rolling pair had readers, so
the translator had no way to consult the opt-in; add `subscribes_extranonce()` to the
vendored sv1 crate and record it on `DownstreamData` at `mining.configure`.

ASICs send `mining.subscribe` first and are unaffected, which is why the live miners
never surfaced this.

⚠ NOT COMPILED — the laptop is out of disk until the WSL vhdx is compacted, and
building would wedge Windows. Build and run against a strict SV1 client before
deploying; this is not yet proven to be why Braiins disconnects.

Claude-Session: https://claude.ai/code/session_01Qdso8qy4cQ2o49L1XyQ75d

* test(sv1): gate the unnegotiated `mining.set_extranonce`, which no case could catch

`fd74982f4` changed behaviour that nothing asserted. The suite already had a probe for
the re-key — `[411-rekey]` — but it drives the *serializer* shape (subscribe, wait,
authorize), where the subscribe response lands before authorise arrives and the post-hoc
path never runs. It therefore reports MET on a node where the defect is live, which is
how this survived the #783/#784/#785 round.

The new case drives the shape the tcpdump actually captured: no `mining.configure` at
all, then `mining.subscribe` and `mining.authorize` pipelined into ONE TCP segment.
Measured against the live fleet on vm1 today, before the fix:

    [unneg-extranonce] FAIL — set_extranonce received: YES — never negotiated
    [411-rekey]        MET  (informational)

So the gate can fail, and it fails on exactly the case the old probe passes. It asserts
absence over a 4 s window; the offending notification fires ~1.2 ms in, so the window is
evidence rather than haste.

⚠ Still open: this proves the protocol violation is gone, NOT that it was why Braiins
disconnects. A synthetic client held the socket for 8 s across the defect without
closing, so the disconnect cause remains unproven.

* test(sv1): add the positive control — opt-in clients must STILL get `set_extranonce`

`[unneg-extranonce]` proves the notification is gone for clients that never asked. On its
own that is half a truth table, and the dangerous half is missing: a change that suppressed
`mining.set_extranonce` for EVERYONE would leave it green while breaking every client that
legitimately re-keys. Nothing in the suite asserted the notification is still delivered when
it is welcome.

`[neg-extranonce]` is the same shape in every respect but one — this client lists
`subscribe-extranonce` in `mining.configure`. The opt-in is the only variable:

    opt-in absent  -> must NOT arrive   ([unneg-extranonce])
    opt-in present -> MUST arrive       ([neg-extranonce])

Measured against the live UNFIXED fleet, both ports, before this change:

    :4444  [unneg-extranonce] FAIL   [neg-extranonce] PASS
    :3333  [unneg-extranonce] FAIL   [neg-extranonce] PASS

So the control passes on today's binary, which is the point — it must hold before AND after,
and only the negative case is allowed to flip.

⚠ The server does not ack `subscribe-extranonce` in its `mining.configure` reply, though it
sends the notification regardless. The fix keys off the client's REQUEST rather than the
server's ack, so the control is unaffected; the ack is reported but deliberately not gating,
to avoid coupling this case to a separate defect.

⚠ `:3333` is not exempt from any of this. Both ports are served by ONE `Sv1Server` and
differ only in the difficulty floor, and the defect measures identically on both — ASICs
escape it through handshake ORDER (subscribe first), not through the port.
defenwycke added a commit that referenced this pull request Aug 29, 2026
Three changes since the deployed binaries, all merged to main:

- #750 — Stage 6 Release B: delete the BFT payout vote and the GHOST-03 ledger sweep
- #782 — share fee drift with the miners instead of the treasury (ships DORMANT)
- #786 — only send `mining.set_extranonce` to clients that asked for it

## What actually changes on the wire

**#786 is the only behaviour change this roll delivers.** Braiins' proxy closes the
connection on receipt of an unnegotiated `mining.set_extranonce`; proven against the real
`braiinssystems/farm-proxy:26.02` image, which names its own reason (`protocol error:
invalid-message-type`, `subs=0`) and whose FIN lands 19.1 ms after that notification and
BEFORE the subscribe response. Both SV1 ports are affected — `:3333` and `:4444` are one
`Sv1Server` differing only in difficulty floor — so this is not farm-tier-only.

**#750 is behaviourally inert**, because `PAYOUT_FROM_SHARD_HEIGHT = 964_100` already
stopped the vote at runtime on 2026-08-26. It deletes machinery that is already unreachable.
⛔ It is NOT reversible by a gate: rollback is the `.bak` binary.

**#782 is inert by construction.** `FEE_DRIFT_MINER_SHARE_HEIGHT = u64::MAX`, so miner pay is
UNCHANGED by this roll — the treasury keeps absorbing every sat above the ~804,768 floor.
Arming it is a separate, deliberate act once the fleet is uniform and clear of this roll's
settling window.

## Binaries

Measured against what the fleet actually RUNS (`57d05b17f`), not against the `v1.11.28` tag —
#783/#784/#785 shipped after that tag, so the tag understates what is already deployed:

    ghost-pool       15 files    (#750, #782)
    translator_sv2    4 files    (#786)
    pool_sv2          0 files    — not rebuilt, not redeployed

`pool_sv2` does not reach the `sv1` crate (`cargo tree -p pool_sv2` finds no `sv1`), so the
config -> `pool_sv2` -> `ghost-pool` ordering constraint from #742 does not apply to this roll.
⚠ Consequence accepted deliberately: `pool_sv2` will keep reporting 1.11.28 while the other
two report 1.11.29, so `check-fleet-uniformity.sh` shows expected skew, not drift.

## Lockfiles

Bumped in THIS commit, not a follow-up. v1.11.28 changed only `Cargo.toml` and needed
`228f5a2c5` to repair both lockfiles afterwards; a stale `fuzz/Cargo.lock` fails CI as a red
"Format" job whose actual failing step is `Fuzz targets build --locked`. Verified with
`grep -F` (the `.` in a version string is a wildcard): 0 stale refs, 35 + 16 new.
@defenwycke
defenwycke deleted the fix/farm-tier-subscribe-extranonce branch August 30, 2026 19:30
defenwycke added a commit that referenced this pull request Sep 6, 2026
…850)

Answers "why did nothing catch this?" — something almost did.
`check-stratum-config-agreement.sh` exists, runs in CI, and is written for exactly
this class of bug. It compared a hardcoded list of keys, and
`open_channel_on_subscribe` was added to both config files long after that list
was written.

That is the second time the same allowlist has missed something. The script's own
comments record the first: #480, where `supported_extensions` /
`required_extensions` diverged silently because they were not listed, so which
attribution path a node took depended on which file provisioned it.

## The mechanism, not another entry

The filter was in the PARSER (`elif key in SHARED`), so unlisted keys never
entered the maps and comparing the maps compared only what someone had remembered
to list. It now collects every key and compares every key present in both files.

`SHARED` becomes a REQUIRED-PRESENT assertion, and `NOT_COMPARED` is an explicit
exception list — the opposite of an allowlist. An unknown key is compared and must
be justified to skip, rather than ignored until noticed. Fail-closed.

Three exceptions, each with a reason: `authority_pubkey` and `user_identity` are
per-node (the installer substitutes shell variables), and bare `port` is
ambiguous across TOML sections — the parser is section-blind outside
`[farm_tier]`, so it compares 8333 (P2P) against 34256 (SV2). The farm port is
compared separately and section-aware.

10 keys compared before, 16 now.

## The divergence it found

⚠ Both in-repo copies said `open_channel_on_subscribe = false` while all eight
nodes run `true`. They agreed with EACH OTHER, which is why a two-file check
could not have caught it even with the key listed — the authoritative third copy
is the fleet.

`install-node.sh` provisions from that file, so node nine would have been given
the pre-#783 behaviour: a serialising client (a proxy, or a rented-hashrate
marketplace like Braiins) answered with an 8-byte placeholder extranonce against
a 12-byte real prefix, building a 4-byte-short coinbase, with EVERY share
rejected. Both copies are now `true`, which the config's own note says is due —
"remove this flag once the fleet is on it", and the fleet has been since
#783/#784/#785.

## Verified by mutation, because I got this wrong once already

My first attempt changed only the comparison loop and left the parser filter in
place. It reported "10/10 shared keys agree" against a deliberately flipped
value — a check that cannot fail, which is the exact failure being fixed. Caught
by flipping the field and expecting red.

The version here fails with
`open_channel_on_subscribe: install-node.sh = 'true' but translator-config.toml = 'false'`
and exits 1.

Claude-Session: https://claude.ai/code/session_01Td1vvfowptTTnu88qG2iym
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant