sv2: serve the Template Distribution Protocol over Noise - #869
Conversation
|
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. |
|
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 ;) |
There was a problem hiding this comment.
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?, |
There was a problem hiding this comment.
[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.
af9abd1 to
5a4273e
Compare
|
@average-gary the constraints-flood note on While the session is holding for IBD, a Please do a final review of the PR. |
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>
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.
0fa306b to
92b1aeb
Compare
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, sendsCoinbaseOutputConstraints, receivesNewTemplate/SetNewPrevHashon every tip, fetches template transactions, and submits solved blocks throughChainHub::accept_block.Plan B of
docs/sv2-template-provider.md(Plan A, budgeted GBT selection, already landed).Surface
rbitcoin-sv2crate: Noise NX listener, per-session task, template builder fromMempoolHub::select_block_templatewith the client's size/sigops budget (sv2-spec 07 §7.1–7.2).--sv2-tp-listen,--sv2-tp-authority-sec/--sv2-tp-authority-sec-file(64 hex or SRIkey-utilsbase58check),--sv2-tp-cert-validity,--sv2-tp-stale-grace. Startup logs the authority pubkey in key-utils form. NixOSservices.rbitcoin.sv2.tp.*(runtime-path secret only; store paths refused).merkle_branch/merkle_root_from_branchin the store, one owner now shared withQuery::merkle_proof(Electrum / Esplora merkle proofs).Hardening
SetupConnection+ first constraints); 30 s write deadline; 64 KiB client frame cap; constraints-flood session close.SubmitSolutionPoW pre-check beforeaccept_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 byaccept_block.Debug, errors never echo the key). No client authentication — documented in the operator guide; loopback default.Tests
rbitcoin-sv2lib 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.Minor observations from final review (non-blocking):
held_loggednever resets once the node leaves IBD, so a later IBD re-entry (large reorg) would not re-log "holding templates". Log-noise only.start_sv2_tpwarns and continues if the bind fails rather than aborting startup — consistent with the existing electrum/esplora service pattern.binary_sv2, and the nightly mutants workspace run covers the new crate.