Skip to content

coinbase: per-job salt push so jobs from an unchanged template are distinct work - #98

Open
Wired4ncer wants to merge 2 commits into
LayerTwo-Labs:mainfrom
Wired4ncer:fix/per-job-coinbase-salt
Open

Wired4ncer wants to merge 2 commits into
LayerTwo-Labs:mainfrom
Wired4ncer:fix/per-job-coinbase-salt

Conversation

@Wired4ncer

Copy link
Copy Markdown
Contributor

Closes #92

What and why

The tip watcher in src/main.c rebuilds the job every 30 s
(now_ms() - s->last_built_ms > 30000), even when the template has not
changed. The rebuilt job had a byte-identical coinbase and merkle root, so
every header a rig could build was the same as in the previous job (only
ntime moved). Rigs that restart their search on each job re-find hashes the
pool has already credited, and the pool answers with a storm of "duplicate
share" rejections.

Fix, as described in #92:

  • Every coinbase builder takes a per-job salt. When it is non-zero it appends
    ONE 5-byte push after the tag in the scriptSig, counted against the 100-byte
    scriptSig cap.
  • The stratum job derives its salt from its job id (FNV-1a, truncated to 32
    bits, forced non-zero).
  • Salt 0 = no push. Callers passing 0 are byte-identical to today.
  • Anything that models scriptSig growth for a byte budget counts the 5 bytes.

Byte layout

scriptSig, in order:

[BIP34 height push] [tag push: len + tag]   [salt push]                 [extranonce1][extranonce2]
                                             0x04 s0 s1 s2 s3  (s = salt, little-endian)

On the template path the salt push follows the tag, which follows the server's
existing scriptSig. The tag push is untouched, so anything reading the tag text
keeps working. The push sits in cb1 (the extranonce placeholder follows it),
cb2 is unchanged, and the scriptSig length varint grows by 5.

Builders covered

coinbase_build, coinbase_build_split, coinbase_build_window,
coinbase_build_from_template, coinbase_build_window_from_template (each
gets uint32_t job_salt right after coinbase_tag). The shared template
implementation and a single cb_tag_push() helper emit the bytes, so there is
one place that defines the layout. Every builder call in stratum.c (8) passes
the job's salt, including the after-block recount that stages the payout
rotation. coinbase_template_reward()'s internal probe passes 0.

Budget accounting

  • 100-byte cap: the push is part of the scriptSig length each builder checks.
  • coinbase_max_bytes (window builders): both builders estimate the fixed
    cost of the coinbase before paying anyone. That estimate now includes the 5
    bytes (COINBASE_JOB_SALT_PUSH_BYTES, shared by both), so a coinbase at the
    edge of the budget is not 5 bytes over it.
  • coinbase_expected_payout_slots() is unchanged. Its fixed 160-byte
    envelope is already optimistic on the classic (non-template) segwit path —
    it does not count the reserved operator output or the witness commitment —
    so its slot estimate can be one too high with or without this change. It
    only shifts the rotation order, never a payment; out of scope here, happy to
    open a separate issue.

Operator-visible effect: because the push counts against the budgets, a
coinbase sitting exactly at either limit can now lose its last payout or be
refused where it was not before. There is a CHANGELOG note.

Tests

tests/test_coinbase.c (dispatching over all five builders):

  • the salted cb1 equals the unsalted one with the length byte +5 and
    04 s0 s1 s2 s3 appended after the tag; cb2 is identical; another salt
    changes only those 4 bytes; same with no tag;
  • salt 0 equals the classic layout (checked against bytes derived from the
    serialization rules, not from the builder);
  • the 100-byte cap: the largest reachable extranonce width with a salt is
    exactly 5 less than without; at the edge it builds, one byte more is refused;
  • the window byte budget: the smallest max_coinbase_bytes that pays all 12
    miners is exactly 5 higher with a salt; at that budget everyone is paid and
    the coinbase fits, one byte less cuts somebody (both window builders);
  • two salts from one template give different coinbase txids (= merkle roots
    with no other transactions).

tests/test_stratum.c:

  • two jobs from one template with different ids send different coinb1 (same
    coinb2) and different merkle roots; a job forced to salt 0 sends a coinb1
    5 bytes shorter;
  • the cross-job-id duplicate-hash test pins J2's salt to J1's so it still
    exercises the server-wide hash ring, and adds J3 with its own salt, which is
    accepted as a new share.
  • the after-block recount renders with the job's salt: at the budget edge where
    a salted coinbase cuts a payee and an unsalted one does not, the found block
    stages the rotation (a recount without the salt would record "everyone
    paid").

Existing builder tests pass 0 and are otherwise unchanged. Full make test
passes (before and after: all green).

Each new test was checked against a mutant of the code it guards (wrong salt
byte, wrong length byte, cap check ignoring the push in the single and template
builders, each window probe ignoring the push, template implementation dropping
the salt, stratum salt constant/zero, one render path dropping the salt); each
fails its test.

🤖 Generated with Claude Code

…stinct work

The tip watcher rebuilds the job every 30 s even when the template has not
changed. The new job then had a byte-identical coinbase and merkle root, so
every header a rig could build was the same as in the previous job (only
ntime moved). Rigs that restart their search on each job re-find hashes the
pool has already credited, and the pool rejects them as duplicate shares in
large numbers.

Every coinbase builder (coinbase_build, coinbase_build_split,
coinbase_build_window, coinbase_build_from_template,
coinbase_build_window_from_template) now takes a per-job salt. When it is
non-zero, ONE 5-byte push (0x04 + 4 bytes little-endian) is appended after
the tag in the scriptSig and counted against the 100-byte scriptSig cap.
Salt 0 emits nothing, so callers passing 0 are byte-identical to before.
The stratum job derives its salt from its job id (FNV-1a, forced non-zero).

The window builders model scriptSig growth to compute the byte budget left
for payouts; both probes now count the 5 bytes through a shared constant
(COINBASE_JOB_SALT_PUSH_BYTES), so a coinbase at the edge of
coinbase_max_bytes is not 5 bytes over budget.

Tests: layout and position of the push in all five builders, salt 0 equals
the classic layout, the 100-byte cap and the window byte budget (exact edge)
both count the push, jobs from one template differ in coinbase and merkle
root. The cross-job-id duplicate test pins both jobs to one salt so it keeps
exercising the server-wide hash ring, and gains a third job with its own salt
that is accepted.

This branch has not been deployed

No deployments
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.

Jobs re-issued from an unchanged template are identical work, so restart-on-new-job rigs re-find credited hashes

1 participant