Skip to content

sv2: serve the Template Distribution Protocol over Noise - #869

Merged
reardencode merged 31 commits into
reardencode:masterfrom
average-gary:sv2/plan-b-tp
Oct 4, 2026
Merged

reardencode merged 31 commits into
reardencode:masterfrom
average-gary:sv2/plan-b-tp

Conversation

@average-gary

Copy link
Copy Markdown
Contributor

Serves the Stratum v2 Template Distribution Protocol (TDP v2, sv2-spec 07) in-process behind --sv2-tp-listen (default off). A Job Declarator Client or pool connects over Noise NX, sends CoinbaseOutputConstraints, receives NewTemplate/SetNewPrevHash on every tip, fetches template transactions, and submits solved blocks through ChainHub::accept_block.

Plan B of docs/sv2-template-provider.md (Plan A, budgeted GBT selection, already landed).

Surface

  • New rbitcoin-sv2 crate: Noise NX listener, per-session task, template builder from MempoolHub::select_block_template with the client's size/sigops budget (sv2-spec 07 §7.1–7.2).
  • Node flags: --sv2-tp-listen, --sv2-tp-authority-sec / --sv2-tp-authority-sec-file (64 hex or SRI key-utils base58check), --sv2-tp-cert-validity, --sv2-tp-stale-grace. Startup logs the authority pubkey in key-utils form. NixOS services.rbitcoin.sv2.tp.* (runtime-path secret only; store paths refused).
  • merkle_branch / merkle_root_from_branch in the store, one owner now shared with Query::merkle_proof (Electrum / Esplora merkle proofs).

Hardening

  • 8-session cap; 10 s setup deadline (handshake + SetupConnection + first constraints); 30 s write deadline; 64 KiB client frame cap; constraints-flood session close.
  • No templates during IBD; SubmitSolution PoW pre-check before accept_block; an empty coinbase witness is filled with the reserved value the commitment was built with; the sv2 timestamp window is logged, consensus time bounds are enforced by accept_block.
  • The authority secret never prints (masked Debug, errors never echo the key). No client authentication — documented in the operator guide; loopback default.

Tests

  • 18 rbitcoin-sv2 lib tests (listener setup/cap, silent-socket and constraints deadlines, write deadline, frame cap, template budget/fee/merkle-path vectors, resend no-op, rate limit, sync gate, PoW pre-check, flood close, tip-race rebuild), store merkle-branch vectors, node config parse units, and one regtest cross-surface journey: bootstrap → tip push → RequestTransactionData → stale grace → SubmitSolution → tip advances.
  • COMPAT row, operator docs, changelog.d entry, NixOS module eval asserts.

Minor observations from final review (non-blocking):

  1. held_logged never resets once the node leaves IBD, so a later IBD re-entry (large reorg) would not re-log "holding templates". Log-noise only.
  2. start_sv2_tp warns and continues if the bind fails rather than aborting startup — consistent with the existing electrum/esplora service pattern.
  3. No fuzz target for the session message handling; wire parsing is delegated to the externally-fuzzed binary_sv2, and the nightly mutants workspace run covers the new crate.

@average-gary

Copy link
Copy Markdown
Contributor Author

full disclosure, i have not mined with this yet. just test harness validation at this point. after the next iteration (fee delta update), I hope to point some hash at it.

@reardencode

Copy link
Copy Markdown
Owner

This is so awesome. Never expected rbitcoin to get this module and it makes me happy-a-f. Gonna take some time to review with my clanker's help because I know dick all about SV2 internals ;)

@rearden-grok rearden-grok Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This PR adds an in-process Stratum V2 Template Distribution server (Noise NX, TDP v2) behind --sv2-tp-listen, which stays off unless an authority secret is configured. Template construction, the merkle-branch move, SubmitSolution, and the NixOS secret path match the consensus checks they hand off to: PoW, BIP34, witness commitment, subsidy, scriptSig length, weight, and sigops are enforced inside accept_block, and a client cannot skip setup. The new sv2 crates are pinned crates.io releases; the second secp256k1 stays inside Noise and does not split workspace types. The one gap is the constraints-flood close, which does not run for the whole IBD hold.

Issue counts by severity

  • bugs: 0
  • suggestions: 1
  • nits: 0

}
}
}
_ => self.publish().await?,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The flood close and the identical-budget no-op both stay disarmed until a template has been sent. built_at is set only after NewTemplate (line 302), and the resend short-circuit also requires current_prev (line 242), which stays None while publish returns early from ChainHub::in_ibd (line 287). After the first constraints message the 10s setup deadline is over, so for the rest of IBD — including a stale tip that never leaves IBD — every CoinbaseOutputConstraints, identical or not, falls through to publish. That schedules spawn_blocking and in_ibd(), and until the IBD latch flips in_ibd takes chain_work_prefix's write lock via meets_minimum_chain_work. The latest budget is already stored in self.constraints, and a later tip event calls publish again, so these calls do not change the template that will be sent. superseded is cleared at the start of publish and is only incremented when a rebuild is already queued inside the 1s cooldown, so a client cycling budgets during IBD is never closed. The post-template path matches the design (the constraints_flood_closes_the_session test) and the operator text, which both describe replacements of a queued budget after a template exists. Eight sessions cannot exhaust the blocking pool — each session awaits one job at a time and the prefix walk is once per process — but the cap claimed for a constraints flood does not cover the longest period the listener is up.

Suggestion: While the session is holding for IBD, store the new budget and return without spawn_blocking. Leaving IBD already arrives as a tip event that calls publish. Apply the same superseded-queue close to budgets that arrive before the first template, or document that the 8-replacement close starts only after the first NewTemplate.

@rearden-grok

rearden-grok Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

@average-gary the constraints-flood note on session.rs is fixed in 0fa306b.

While the session is holding for IBD, a CoinbaseOutputConstraints only stores the budget. The tip that leaves IBD already calls publish, so those messages no longer take the blocking pool, and the 8-replacement close stays the cap on a queued build after a template exists. constraints_while_ibd_keep_the_last_budget covers a flood past that cap: the session stays up, and the tip template uses the last budget.

Please do a final review of the PR.

average-gary and others added 25 commits October 4, 2026 16:16
New crate `rbitcoin-sv2`: a TCP listener that runs the Noise_NX responder
(authority keypair from the workspace secp256k1, raw bytes into
noise_sv2) and answers SetupConnection for the Template Distribution
Protocol. Nonzero flags echo back as unsupported-feature-flags; another
protocol or no version-2 overlap is an Error, and every Error closes the
connection. At most 8 concurrent sessions (named RAM trade on
MAX_SESSIONS); the next connection is closed before the handshake.

Wire crates are pinned to the Step 0 spike set minus parsers_sv2: the
known common and TDP types decode with binary_sv2::from_bytes, so the
three unused subprotocol crates stay out of the lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The TDP NewTemplate carries the coinbase merkle path, and Electrum's
blockchain.transaction.get_merkle already built the same branch with an
inline sha256d loop. Put merkle_branch next to merkle_root_from_txids in
the store, share the odd-node level step, and switch query onto it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The session builds a template in a blocking region from the client's
coinbase constraints: weight reserve max(1168 + 4·size, 2000) WU, the
client's sigops as the reserve, the BIP34 height push as the prefix,
subsidy plus selected fees as the value, the witness commitment as the
only output, and the coinbase merkle path from merkle_branch. Each
build gets the next template_id for the session.

Sending here keeps the builder on the shipped path; the plan doc moves
tx retention to B5 (its first reader) and SetNewPrevHash, the sync
gate, and node wiring stay in B4. rbitcoin-net re-exports SelectBudget
next to Selected, since select_block_template takes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A template on a new prev hash goes out as future and is activated by
SetNewPrevHash (header_timestamp above MTP, next-block bits and target).
Later templates on the same tip are non-future. No template is built while
ChainHub::in_ibd(); the session rechecks on tip events, since leaving IBD
always comes with a new tip. The doc splits B4 into this step and the node
wiring journey (B4b).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The node and the test crate need the same exact versions as rbitcoin-sv2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
--sv2-tp-listen needs --sv2-tp-authority-sec (a secp256k1 secret, checked
at parse time); --sv2-tp-cert-validity defaults to 3600 s. The listener
starts with the other servers, logs the authority x-only pubkey, and shuts
down with them. The sv2_tp_bootstrap journey holds templates on a stale
chain, clears the gate with generateblock, and checks the pair and a
mempool transaction against RPC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each session keeps its last three templates with their full transactions
(the RAM trade the design doc names), so the data does not depend on the
mempool still holding them. A dropped id answers stale-template-id; an id
never sent answers template-id-not-found.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each session now selects over client frames, tip events, and the next
retire deadline. Frames come from a reader task on the split Noise
connection, since a Noise recv is not cancel-safe. A new tip rebuilds with
the session's constraints and sends the future template and its
SetNewPrevHash unasked. Templates on the old prev hash keep answering for
--sv2-tp-stale-grace (default 10 s), then answer stale-template-id. The IBD
gate is the same publish path, now cleared by the tip event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A client that solves a served template sends SubmitSolution. The session
rebuilds the block from the retained template: prev hash and bits from the
template, a merkle root recomputed over the client's coinbase plus the
retained txs, and version, time, and nonce from the message. It runs
ChainHub::accept_block in a blocking region.

Bad solutions are logged and dropped, and the session keeps serving. That
covers an undecodable message, an unknown or retired template, a
header_timestamp outside [sent SNPH timestamp, + wall time since sent], and
an undecodable coinbase. Every retained template records the SNPH it was
served under, so the window check has its reference point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A hex value passed to --sv2-tp-authority-sec shows in ps. Passed through
a NixOS unit, it also lands in the world-readable store.
--sv2-tp-authority-sec-file PATH (conf sv2_tp_authority_sec_file) reads
the same 64-hex key at parse time and shares the hex and SecretKey check.
Errors name the knob and never echo the key.

The plan doc splits B8: B8a is this key; B8b is the operator surface,
where the NixOS module takes only the file path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
services.rbitcoin.sv2.tp.* is added: enable, address, port (default 8442),
authoritySecretFile, certValidity, staleGrace, and openFirewall. It maps to
the --sv2-tp-* flags ahead of extraArgs. The module takes only a runtime
path for the authority key (--sv2-tp-authority-sec-file), so the secret
never reaches the store or argv. Enabling without that path fails an
assertion. The eval check pins the argv, the defaults, the firewall port,
extraArgs staying last, and the absence of an inline secret.

The operator interfaces guide documents the five flags and conf keys.
COMPAT gains the SV2 TDP row; the "no stratum" GBT row is unchanged. A
changelog.d fragment is added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SRI clients (JDC, pool) configure the Template Provider authority as a
key-utils Secp256k1PublicKey: base58check of version 1u16 LE plus the
x-only key. The startup log printed hex, which no client accepts, so an
operator could not connect without converting it by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SRI deployments generate and store the TP authority secret as a
key-utils Secp256k1SecretKey (base58check of the raw 32 bytes). Accept
that form in --sv2-tp-authority-sec and the key file so the same key
works without converting it to hex. 64 hex still parses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ChainHub::connect_at publishes the store tip, then strips the block's
txs from the mempool, then sends the tip event. A build in that window
(CoinbaseOutputConstraints, or the previous event's rebuild during
back-to-back connects) lands on the new prev hash with txs the block
confirmed. The session then skipped the event because current_prev
already matched, so every solution on that template failed until the
next block. A failed reorg's rollback reconnects the same hash through
connect_at with the same window.

Every build now sets a flag that each tip event clears. An event for
current_prev rebuilds once, as a non-future template on the same prev
hash, iff a template was built since the last event; a repeat event
with no build between is skipped. Comparing event hashes instead would
skip the rollback's event, since the store tip moves back to the same
hash without one. Connect ordering in chain.rs is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Any client could send ~100-byte SubmitSolutions with a bad nonce, and
each one cloned every template tx, hashed every txid, then took
ChainHub's connect_lock, overwrote the compact-block prefill slot and
ran the mempool pre-checks before accept_block rejected it as
high-hash.

The session now folds the coinbase txid over the retained template's
merkle path (coinbase is leaf 0, always the left child), builds the
header, and drops the solution unless it meets the template's n_bits
target. The folded root is the header's merkle root, so the full txid
list is no longer hashed here; accept_block still checks it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A session takes one of the 8 slots at TCP accept, and neither the Noise
handshake reads nor the first recv before SetupConnection had a deadline,
so 8 sockets that connect and send nothing locked every client out of
the TP until restart. The handshake, SetupConnection, and the first
CoinbaseOutputConstraints now must arrive within
Sv2TpConfig::setup_timeout (the node passes SETUP_TIMEOUT, 10 s) or the
socket is closed and its slot freed. The constraints are covered because
a TDP session without them never gets a template and never writes, so
nothing else would ever free the slot; other frames before them are
handled but do not extend the deadline. There is still no read deadline
after that: TDP has no keepalive and a client may stay silent while the
TP pushes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NoiseWriter::send used write_all with no deadline. A client that floods
requests and never reads fills the socket buffers, the session blocks
in the write, its reader pump stops, and the slot is held forever. Each
socket write now has Sv2TpConfig::write_timeout (the node passes
WRITE_TIMEOUT, 30 s) to make progress, or the session closes. The
deadline is per write call rather than per frame so a slow but live
reader still receives a multi-MB RequestTransactionData.Success.

TCP keepalive is not set: tokio's TcpStream does not expose it and
socket2 is not a dependency of this crate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SV2 frame header carries a 24-bit payload length, so a client could
make its session buffer ~16 MB per frame (codec buffers plus the owned
payload copy). The largest TDP client message is SubmitSolution: 20 fixed
bytes plus a B064K coinbase, so MAX_CLIENT_PAYLOAD is 65557 bytes and a
larger client frame closes the session.

codec_sv2 7.0.0 keeps the decrypted header private and asks for at most
one chunk per read without crossing a frame boundary, so the reader
counts encrypted bytes per frame and fails before reading the chunk that
would pass the cap. The test client reader stays uncapped: TP frames such
as RequestTransactionData.Success are legitimately multi-MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The session dropped solutions whose header_timestamp fell outside
[sent SetNewPrevHash timestamp, + wall time since]. A miner whose clock
runs a few seconds fast finds a consensus-valid block and lost it. PoW
is now checked before accept_block, so a bad-nonce spam cannot reach the
connect path; accept_block enforces the consensus time bounds (> MTP,
< now + 2h). The window check now only logs (with the template id),
after the PoW check so a PoW-failing solution logs one line, and the
solution is still submitted.

Tests share a first-template setup helper; the new test submits a
PoW-valid solution 5 s past the window and asserts it becomes the tip.
Design and operator docs no longer say out-of-window solutions drop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NodeConfig derives Debug and NodeHandle's Debug prints the config, so the
raw authority secret bytes landed in any debug dump. Wrap the field in
Sv2AuthoritySecret, whose Debug masks it like the TorControlOpts password.

The sv2_tp_authority_sec_file read error echoed the path; an operator who
passes the key itself to the file knob got the key printed. Name the knob
and the IO error only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A session computes its grace deadline as Instant + stale_grace, which
panics on overflow, and noise_sv2 casts cert_validity seconds to u32, so a
value past u32::MAX wraps into a short or already-expired certificate.

run_sv2_tp now returns InvalidInput for cert_validity over u32::MAX
seconds or stale_grace over MAX_STALE_GRACE (one day). Config parse
rejects the same values naming the knob, the CLI help states the bounds,
and the NixOS module types use ints.between.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Inserting parse_authority_sec split parse_conf_bool's doc: its 1/true/yes/on
line ended up above parse_authority_sec, and parse_conf_bool had none. Move
it back so each function carries its own doc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing outside rbitcoin-sv2 reads either; callers use authority_key() for
the printable key and the session cap is an internal limit. Only the crate's
own tests use them. Public docs now name them as plain code instead of
intra-doc links to private items.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every CoinbaseOutputConstraints ran template::build under the mempool
read lock on a blocking thread. A client looping the message, identical
or alternating between two budgets, burned blocking threads and
contended with admission, GBT, and relay at its send rate.

Once a template on the current prev hash was sent, a resend of the same
budget is now a no-op. A changed budget within CONSTRAINTS_COOLDOWN (1 s)
of the session's last template is deferred to the end of that interval
and built once on the latest budget, so constraints-triggered builds are
capped at one per second per session; tip events still rebuild at once.
More than MAX_SUPERSEDED_CONSTRAINTS (8) budgets that each replace a
still-queued one before it is built close the session, so a flooding
client gives its slot back; a client pacing one budget per cooldown is
only made to wait.
Refreshing on mempool gains with the tip unchanged is Plan C, so the
bootstrap journey now changes the budget where it wants a rebuild.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…value

The template's witness commitment is built with a 32-byte zero reserved
value. SRI clients put Witness[[0; 32]] on the coinbase themselves, but a
client that sends a coinbase with no witness had its block rejected by
accept_block with bad-witness-nonce-size, losing a valid solution.

on_submit_solution now fills an empty coinbase input witness with the
same reserved value (a shared WITNESS_RESERVED_VALUE in template.rs)
before hashing. The txid, and so the merkle root and PoW pre-check, do
not cover the witness. A non-empty witness is submitted as sent.

The fill runs only when the coinbase carries a witness commitment output
(rbitcoin_consensus::witness_commitment_vout_index, now public), as
Core's UpdateUncommittedBlockStructures does. A witness-less block whose
coinbase drops the commitment is valid; filling its witness would turn
it into a "missing witness commitment" reject.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
average-gary and others added 6 commits October 4, 2026 16:19
Noise NX authenticates the TP's authority key to the client, not the
client to the TP, and SetupConnection carries no credential. Anyone who
can reach the listen port can request templates and submit solutions,
so operators need to bind to loopback (the NixOS module default) or
firewall the port to the JDC host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On testnet3 and testnet4 the template's n_bits is fixed at build time
from header_timestamp. A miner that rolls ntime past prev + 20 min gets
a header whose expected bits are the pow limit, so validation rejects
it, and nothing re-pushes a template at that boundary. Record this as
C3: the session arms its own deadline at prev + 2 x target spacing + 1 s
and rebuilds there. The + 1 matters because the consensus rule selects
the pow limit only strictly after the boundary. C1 is event-driven (it
fires only when the mempool template counter advances), so a quiet
mempool would never reach the boundary through it. Also note that
consensus does not enforce the BIP94 timewarp floor yet, which is
outside this plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The flooding client never sent CoinbaseOutputConstraints, so the
session was also racing the 10 s setup deadline. On the CI runner the
flood had not filled the socket buffers by then; the deadline closed
the session and every later write failed fast with EPIPE, which the
loop swallowed as Ok(Err(_)) while waiting for a timeout, ending in
"socket buffers never filled" after 1M sends.

Send constraints after setup so only the write deadline can close the
session, and count a fast write error as jammed: the pipe is dead
either way.
sv2-spec 07 §7.4 carries nBits in SetNewPrevHash, once per prev hash;
NewTemplate has no bits. Each build recomputed them from the clock
(expected_next_bits at header_timestamp), and SubmitSolution assembled
the header with the retained template's own bits. On min-difficulty
networks (testnet3, testnet4) a rebuild on the same prev hash after
prev + 2 x spacing stored the pow-limit bits while the client kept
hashing with the bits it was sent, so every solution on that template
was rejected as bad-diffbits. Constraint changes and the tip-race
rebuild both hit it; Plan C's fee push would too.

A template on the current prev hash now takes the nBits and target of
that prev hash's SetNewPrevHash. Mainnet and signet bits depend only on
the prev hash, so they are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
C3 planned a NewTemplate re-push at prev + 20 min carrying the pow-limit
nBits. NewTemplate has no nBits field: sv2-spec 07 §7.4 sends nBits once
per prev hash in SetNewPrevHash, since templates refresh often and the
prev hash changes only on a block. A same-hash SetNewPrevHash is outside
§7.4 too, so no message can move a client to the 20-minute pow limit.

C3 is removed. Risks records the min-difficulty limitation: a TP client
mines at the sent bits, and a solution rolled past prev + 2 x spacing is
bad-diffbits unless those bits were already the limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A CoinbaseOutputConstraints while the session is holding for IBD only
stores the budget. The tip that leaves IBD already calls publish, and
the 8-replacement close stays the cap on a queued post-template build.
@reardencode
reardencode merged commit 99307d1 into reardencode:master Oct 4, 2026
23 of 24 checks passed
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.

2 participants