From 5dcefcec957f29e6d7077d5463c4694ff2ce8f2a Mon Sep 17 00:00:00 2001 From: Gary Krause Date: Tue, 29 Sep 2026 10:02:45 -0400 Subject: [PATCH 01/31] sv2: TDP listener with Noise responder and SetupConnection 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 --- Cargo.lock | 309 +++++++++++++++++++++- Cargo.toml | 2 + crates/rbitcoin-sv2/Cargo.toml | 23 ++ crates/rbitcoin-sv2/src/lib.rs | 122 +++++++++ crates/rbitcoin-sv2/src/listener_tests.rs | 85 ++++++ crates/rbitcoin-sv2/src/session.rs | 85 ++++++ crates/rbitcoin-sv2/src/testutil.rs | 54 ++++ crates/rbitcoin-sv2/src/transport.rs | 144 ++++++++++ docs/CRATES.md | 1 + docs/sv2-template-provider.md | 9 +- 10 files changed, 826 insertions(+), 8 deletions(-) create mode 100644 crates/rbitcoin-sv2/Cargo.toml create mode 100644 crates/rbitcoin-sv2/src/lib.rs create mode 100644 crates/rbitcoin-sv2/src/listener_tests.rs create mode 100644 crates/rbitcoin-sv2/src/session.rs create mode 100644 crates/rbitcoin-sv2/src/testutil.rs create mode 100644 crates/rbitcoin-sv2/src/transport.rs diff --git a/Cargo.lock b/Cargo.lock index 0d2179296..a61dbd1e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,41 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "arc-swap" version = "1.9.2" @@ -96,6 +131,15 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "binary_sv2" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ec138d56d7501e4a365ce858465f827197835da7fda8b56f9a1ddc0f4aa012e" +dependencies = [ + "derive_codec_sv2", +] + [[package]] name = "bip324" version = "0.11.0" @@ -104,7 +148,7 @@ checksum = "a03c45ad0d13f5df3aff10b7fdf6897dba1e9fc819cf932589d48ddba2536c94" dependencies = [ "bitcoin_hashes 0.16.0", "chacha20-poly1305", - "secp256k1", + "secp256k1 0.29.1", "tokio", ] @@ -121,7 +165,7 @@ dependencies = [ "bitcoin_hashes 0.14.101", "hex-conservative 0.2.3", "hex_lit", - "secp256k1", + "secp256k1 0.29.1", ] [[package]] @@ -174,6 +218,15 @@ dependencies = [ "bitcoin-consensus-encoding", ] +[[package]] +name = "bitcoin_hashes" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446819536d8121575eeb7e89efdbadb3f055e87e4bb66c6679a6d5cc2f4b64fd" +dependencies = [ + "hex-conservative 0.1.2", +] + [[package]] name = "bitcoin_hashes" version = "0.14.101" @@ -200,6 +253,15 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "buffer_sv2" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5c1cac04b754ae9750cd8b2d9948bd8db2b2b00f8192024a500e7ba8a3fb2eb" +dependencies = [ + "aes-gcm", +] + [[package]] name = "bytes" version = "1.12.1" @@ -222,18 +284,84 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20-poly1305" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b4b0fc281743d80256607bd65e8beedc42cb0787ea119c85b81b4c0eab85e5f" +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "codec_sv2" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27fa7e278b547a7793e352ec2d9346147758a45d7f6e0354762c632a5ef1022b" +dependencies = [ + "binary_sv2", + "buffer_sv2", + "framing_sv2", + "noise_sv2", + "rand", +] + +[[package]] +name = "common_messages_sv2" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fc74eef0c73f31e57bbe5ecbda80629bc98b2dd5ca8ba7fc9a145ec22166c6" +dependencies = [ + "binary_sv2", +] + [[package]] name = "const-oid" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -243,6 +371,16 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -252,6 +390,21 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "derive_codec_sv2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05768350591ccb368bd0ec96dc9ca27ca6a04a4cc298718f139e323c27a89835" + [[package]] name = "digest" version = "0.11.3" @@ -259,7 +412,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "const-oid", - "crypto-common", + "crypto-common 0.2.2", ] [[package]] @@ -287,6 +440,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "framing_sv2" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cedad16f0ac769b23506b19b0b1004a40d822d30b35106ffb51845e3b8da405" +dependencies = [ + "binary_sv2", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -326,6 +488,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -348,6 +520,22 @@ dependencies = [ "r-efi", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "hex-conservative" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20" + [[package]] name = "hex-conservative" version = "0.2.3" @@ -470,6 +658,15 @@ dependencies = [ "tower-service", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "io-uring" version = "0.7.15" @@ -494,7 +691,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", ] [[package]] @@ -572,12 +769,30 @@ version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "add0ac067452ff1aca8c5002111bd6b1c895baee6e45fcbc44e0193aea17be56" +[[package]] +name = "noise_sv2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21d54d5ba485f6a44a4195336cdbedca81e9afa875e656d7b28661ca9974c475" +dependencies = [ + "chacha20poly1305", + "rand", + "secp256k1 0.28.2", + "zeroize", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -590,6 +805,29 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -834,6 +1072,19 @@ dependencies = [ "rbitcoin-primitives", ] +[[package]] +name = "rbitcoin-sv2" +version = "0.7.99" +dependencies = [ + "binary_sv2", + "bitcoin", + "codec_sv2", + "common_messages_sv2", + "noise_sv2", + "rbitcoin-log", + "tokio", +] + [[package]] name = "rbitcoin-test" version = "0.7.99" @@ -875,6 +1126,17 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "secp256k1" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24b59d129cdadea20aea4fb2352fa053712e5d713eee47d700cd4b2bc002f10" +dependencies = [ + "bitcoin_hashes 0.13.1", + "rand", + "secp256k1-sys 0.9.2", +] + [[package]] name = "secp256k1" version = "0.29.1" @@ -883,7 +1145,16 @@ checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ "bitcoin_hashes 0.14.101", "rand", - "secp256k1-sys", + "secp256k1-sys 0.10.1", +] + +[[package]] +name = "secp256k1-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d1746aae42c19d583c3c1a8c646bfad910498e2051c551a7f2e3c0c9fbb7eb" +dependencies = [ + "cc", ] [[package]] @@ -1016,6 +1287,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -1169,6 +1446,22 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1210,6 +1503,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 37b609900..8af77c406 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/rbitcoin-rpc", "crates/rbitcoin-electrum", "crates/rbitcoin-esplora", + "crates/rbitcoin-sv2", "crates/rbitcoin-cli", "crates/rbitcoin-node", "crates/rbitcoin-test", @@ -37,6 +38,7 @@ rbitcoin-net = { path = "crates/rbitcoin-net" } rbitcoin-rpc = { path = "crates/rbitcoin-rpc" } rbitcoin-electrum = { path = "crates/rbitcoin-electrum" } rbitcoin-esplora = { path = "crates/rbitcoin-esplora" } +rbitcoin-sv2 = { path = "crates/rbitcoin-sv2" } rbitcoin-cli = { path = "crates/rbitcoin-cli" } rbitcoin-node = { path = "crates/rbitcoin-node" } rbitcoin-test = { path = "crates/rbitcoin-test" } diff --git a/crates/rbitcoin-sv2/Cargo.toml b/crates/rbitcoin-sv2/Cargo.toml new file mode 100644 index 000000000..4997ce2da --- /dev/null +++ b/crates/rbitcoin-sv2/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "rbitcoin-sv2" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true +description = "Stratum v2 Template Distribution Protocol server (Noise over TCP)" + +[dependencies] +rbitcoin-log = { workspace = true } +bitcoin = { workspace = true } +tokio = { workspace = true } +# Stratum v2 wire crates, pinned to the Step 0 spike set +# (docs/sv2-template-provider.md). Messages decode with `binary_sv2::from_bytes` +# on the known types; `parsers_sv2` is not needed. +noise_sv2 = "=2.0.0" +codec_sv2 = { version = "=7.0.0", features = ["noise_sv2"] } +binary_sv2 = "=7.0.0" +common_messages_sv2 = "=9.0.0" + +[lints] +workspace = true diff --git a/crates/rbitcoin-sv2/src/lib.rs b/crates/rbitcoin-sv2/src/lib.rs new file mode 100644 index 000000000..78033ae85 --- /dev/null +++ b/crates/rbitcoin-sv2/src/lib.rs @@ -0,0 +1,122 @@ +//! Stratum v2 Template Distribution Protocol server (sv2-spec 07). +//! +//! Noise_NX over TCP is the only transport. Plan and constraints: +//! `docs/sv2-template-provider.md`. + +mod session; +pub mod testutil; +mod transport; + +use bitcoin::secp256k1::{Keypair, Secp256k1}; +use std::io; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::net::TcpListener; +use tokio::sync::Semaphore; +use tokio::task::JoinHandle; + +pub use transport::Frame; + +/// Concurrent TDP sessions. RAM trade (CONTRIBUTING 9): each session retains +/// the witness-serialized txs of its live templates (≤ ~4 MB × ~3), so the +/// cap bounds retention at ≤ ~96 MB. +pub const MAX_SESSIONS: usize = 8; + +pub struct Sv2TpConfig { + pub listen: SocketAddr, + /// Authority secret key; clients pin its x-only public key. + pub authority_secret: [u8; 32], + /// Validity of the per-connection Noise certificate signed by the authority. + pub cert_validity: Duration, +} + +pub struct Sv2TpHandle { + pub local_addr: SocketAddr, + /// X-only authority public key the clients verify the certificate against. + pub authority_pubkey: [u8; 32], + task: JoinHandle<()>, + sessions: Arc>>>, +} + +impl Sv2TpHandle { + pub async fn shutdown(self) { + self.task.abort(); + let mut sessions = self.sessions.lock().unwrap_or_else(|e| e.into_inner()); + for t in sessions.drain(..) { + t.abort(); + } + } +} + +/// Bind the listener and serve TDP sessions until [`Sv2TpHandle::shutdown`]. +/// +/// At [`MAX_SESSIONS`] the next connection is closed before the handshake; +/// existing sessions are not touched. +pub async fn run_sv2_tp(config: Sv2TpConfig) -> io::Result { + let keypair = + Keypair::from_seckey_slice(&Secp256k1::new(), &config.authority_secret).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("sv2 authority key: {e}"), + ) + })?; + let authority_pubkey = keypair.x_only_public_key().0.serialize(); + let authority_secret = config.authority_secret; + let cert_validity = config.cert_validity; + let listener = TcpListener::bind(config.listen).await?; + let local_addr = listener.local_addr()?; + let slots = Arc::new(Semaphore::new(MAX_SESSIONS)); + let sessions: Arc>>> = Arc::new(Mutex::new(Vec::new())); + let sessions_c = sessions.clone(); + + let task = tokio::spawn(async move { + loop { + let (stream, peer) = match listener.accept().await { + Ok(a) => a, + Err(e) => { + rbitcoin_log::warn!("sv2: accept failed ({e})"); + tokio::time::sleep(Duration::from_millis(100)).await; + continue; + } + }; + let Ok(slot) = slots.clone().try_acquire_owned() else { + rbitcoin_log::warn!("sv2: reject {peer} (at max_sessions={MAX_SESSIONS})"); + drop(stream); + continue; + }; + let responder = match noise_sv2::Responder::from_authority_kp( + &authority_pubkey, + &authority_secret, + cert_validity, + ) { + Ok(r) => r, + Err(e) => { + rbitcoin_log::warn!("sv2: responder for {peer} ({e:?})"); + continue; + } + }; + rbitcoin_log::info!("sv2: connect {peer}"); + let h = tokio::spawn(async move { + let _slot = slot; + match session::serve(stream, responder).await { + Ok(()) => rbitcoin_log::info!("sv2: disconnect {peer}"), + Err(e) => rbitcoin_log::info!("sv2: disconnect {peer} ({e})"), + } + }); + let mut g = sessions_c.lock().unwrap_or_else(|e| e.into_inner()); + g.retain(|t| !t.is_finished()); + g.push(h); + } + }); + + Ok(Sv2TpHandle { + local_addr, + authority_pubkey, + task, + sessions, + }) +} + +#[cfg(test)] +mod listener_tests; diff --git a/crates/rbitcoin-sv2/src/listener_tests.rs b/crates/rbitcoin-sv2/src/listener_tests.rs new file mode 100644 index 000000000..4198b55f7 --- /dev/null +++ b/crates/rbitcoin-sv2/src/listener_tests.rs @@ -0,0 +1,85 @@ +use crate::testutil::TpClient; +use crate::{run_sv2_tp, Sv2TpConfig, MAX_SESSIONS}; +use common_messages_sv2::{ + SetupConnectionError, SetupConnectionSuccess, MESSAGE_TYPE_SETUP_CONNECTION_ERROR, + MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, +}; +use std::net::SocketAddr; +use std::time::{Duration, Instant}; + +const TDP: u8 = 2; + +async fn connect_when_free(addr: SocketAddr, pk: [u8; 32]) -> TpClient { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match TpClient::connect(addr, pk).await { + Ok(c) => return c, + Err(e) if Instant::now() > deadline => panic!("no free session slot: {e}"), + Err(_) => tokio::time::sleep(Duration::from_millis(20)).await, + } + } +} + +async fn expect_error(c: &mut TpClient, flags: u32, code: &str) { + let mut f = c.recv().await.expect("setup reply"); + assert_eq!(f.msg_type, MESSAGE_TYPE_SETUP_CONNECTION_ERROR); + let e: SetupConnectionError = binary_sv2::from_bytes(&mut f.payload).expect("decode"); + assert_eq!(e.flags, flags); + assert_eq!(e.error_code.as_utf8_or_hex(), code); + let closed = tokio::time::timeout(Duration::from_secs(5), c.recv()).await; + assert!( + matches!(closed, Ok(Err(_))), + "connection must close after SetupConnection.Error" + ); +} + +#[tokio::test] +async fn setup_connection_success_errors_and_session_cap() { + let tp = run_sv2_tp(Sv2TpConfig { + listen: "127.0.0.1:0".parse().unwrap(), + authority_secret: [7; 32], + cert_validity: Duration::from_secs(3600), + }) + .await + .expect("listen"); + let (addr, pk) = (tp.local_addr, tp.authority_pubkey); + + let mut live = Vec::new(); + for _ in 0..MAX_SESSIONS { + let mut c = TpClient::connect(addr, pk).await.expect("handshake"); + c.setup_connection(TDP, 2, 2, 0).await.unwrap(); + let mut f = c.recv().await.expect("setup reply"); + assert_eq!(f.msg_type, MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS); + let ok: SetupConnectionSuccess = binary_sv2::from_bytes(&mut f.payload).expect("decode"); + assert_eq!((ok.used_version, ok.flags), (2, 0)); + live.push(c); + } + + let extra = TpClient::connect(addr, pk).await; + assert!( + extra.is_err(), + "session over the cap must close before the handshake" + ); + for c in live.iter_mut().step_by(MAX_SESSIONS - 1) { + let still_open = tokio::time::timeout(Duration::from_millis(200), c.recv()).await; + assert!( + still_open.is_err(), + "existing session must stay up at the cap" + ); + } + drop(live); + + let mut c = connect_when_free(addr, pk).await; + c.setup_connection(TDP, 2, 2, 0b101).await.unwrap(); + expect_error(&mut c, 0b101, "unsupported-feature-flags").await; + + let mut c = connect_when_free(addr, pk).await; + c.setup_connection(0, 2, 2, 0).await.unwrap(); + expect_error(&mut c, 0, "unsupported-protocol").await; + + let mut c = connect_when_free(addr, pk).await; + c.setup_connection(TDP, 3, 4, 0).await.unwrap(); + expect_error(&mut c, 0, "protocol-version-mismatch").await; + + tp.shutdown().await; +} diff --git a/crates/rbitcoin-sv2/src/session.rs b/crates/rbitcoin-sv2/src/session.rs new file mode 100644 index 000000000..98bbb8559 --- /dev/null +++ b/crates/rbitcoin-sv2/src/session.rs @@ -0,0 +1,85 @@ +//! One TDP session: Noise handshake, `SetupConnection`, then TDP messages. + +use crate::transport::{Frame, NoiseConn}; +use binary_sv2::Str0255; +use common_messages_sv2::{ + Protocol, SetupConnection, SetupConnectionError, SetupConnectionSuccess, + ERROR_CODE_SETUP_CONNECTION_PROTOCOL_VERSION_MISMATCH, + ERROR_CODE_SETUP_CONNECTION_UNSUPPORTED_FEATURE_FLAGS, + ERROR_CODE_SETUP_CONNECTION_UNSUPPORTED_PROTOCOL, MESSAGE_TYPE_SETUP_CONNECTION, + MESSAGE_TYPE_SETUP_CONNECTION_ERROR, MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, +}; +use noise_sv2::Responder; +use std::io; +use tokio::net::TcpStream; + +const TDP_VERSION: u16 = 2; + +enum Phase { + AwaitingSetup, + AwaitingConstraints, +} + +enum Next { + Continue(Phase), + Close, +} + +pub(crate) async fn serve(stream: TcpStream, responder: Box) -> io::Result<()> { + let mut conn = NoiseConn::accept(stream, responder).await?; + let mut phase = Phase::AwaitingSetup; + loop { + let frame = conn.recv().await?; + let next = match phase { + Phase::AwaitingSetup => on_setup(&mut conn, frame).await?, + Phase::AwaitingConstraints => Next::Continue(Phase::AwaitingConstraints), + }; + phase = match next { + Next::Continue(p) => p, + Next::Close => return Ok(()), + }; + } +} + +fn setup_error(m: &SetupConnection) -> Option<(u32, &'static str)> { + if m.protocol != Protocol::TemplateDistributionProtocol { + return Some((0, ERROR_CODE_SETUP_CONNECTION_UNSUPPORTED_PROTOCOL)); + } + if !(m.min_version..=m.max_version).contains(&TDP_VERSION) { + return Some((0, ERROR_CODE_SETUP_CONNECTION_PROTOCOL_VERSION_MISMATCH)); + } + // TDP defines no SetupConnection flags: every set bit is unsupported. + if m.flags != 0 { + return Some(( + m.flags, + ERROR_CODE_SETUP_CONNECTION_UNSUPPORTED_FEATURE_FLAGS, + )); + } + None +} + +async fn on_setup(conn: &mut NoiseConn, mut frame: Frame) -> io::Result { + if frame.msg_type != MESSAGE_TYPE_SETUP_CONNECTION { + rbitcoin_log::info!("sv2: message {:#x} before SetupConnection", frame.msg_type); + return Ok(Next::Close); + } + let Ok(setup) = binary_sv2::from_bytes::(&mut frame.payload) else { + rbitcoin_log::info!("sv2: undecodable SetupConnection"); + return Ok(Next::Close); + }; + if let Some((flags, code)) = setup_error(&setup) { + let error_code = Str0255::try_from(code) + .map_err(|e| io::Error::other(format!("sv2 error code: {e:?}")))?; + let reply = SetupConnectionError { flags, error_code }; + conn.send(MESSAGE_TYPE_SETUP_CONNECTION_ERROR, reply) + .await?; + return Ok(Next::Close); + } + let reply = SetupConnectionSuccess { + used_version: TDP_VERSION, + flags: 0, + }; + conn.send(MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, reply) + .await?; + Ok(Next::Continue(Phase::AwaitingConstraints)) +} diff --git a/crates/rbitcoin-sv2/src/testutil.rs b/crates/rbitcoin-sv2/src/testutil.rs new file mode 100644 index 000000000..21176f05a --- /dev/null +++ b/crates/rbitcoin-sv2/src/testutil.rs @@ -0,0 +1,54 @@ +//! Test-only TDP client: a Noise initiator pinned to the TP's authority key. + +use crate::transport::{Frame, NoiseConn}; +use binary_sv2::Str0255; +use common_messages_sv2::{Protocol, SetupConnection, MESSAGE_TYPE_SETUP_CONNECTION}; +use std::io; +use std::net::SocketAddr; +use tokio::net::TcpStream; + +pub struct TpClient { + conn: NoiseConn, +} + +impl TpClient { + /// TCP connect and complete the NX handshake against `authority_pubkey`. + pub async fn connect(addr: SocketAddr, authority_pubkey: [u8; 32]) -> io::Result { + let stream = TcpStream::connect(addr).await?; + let initiator = noise_sv2::Initiator::from_raw_k(authority_pubkey) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, format!("{e:?}")))?; + Ok(Self { + conn: NoiseConn::connect(stream, initiator).await?, + }) + } + + /// Send `SetupConnection`; `protocol` is the raw discriminant (2 = TDP). + pub async fn setup_connection( + &mut self, + protocol: u8, + min_version: u16, + max_version: u16, + flags: u32, + ) -> io::Result<()> { + let protocol = Protocol::try_from(protocol) + .map_err(|()| io::Error::new(io::ErrorKind::InvalidInput, "protocol"))?; + let s = |v: &'static str| Str0255::try_from(v).expect("short literal"); + let msg = SetupConnection { + protocol, + min_version, + max_version, + flags, + endpoint_host: s("127.0.0.1"), + endpoint_port: 0, + vendor: s("rbitcoin-test"), + hardware_version: s(""), + firmware: s(""), + device_id: s(""), + }; + self.conn.send(MESSAGE_TYPE_SETUP_CONNECTION, msg).await + } + + pub async fn recv(&mut self) -> io::Result { + self.conn.recv().await + } +} diff --git a/crates/rbitcoin-sv2/src/transport.rs b/crates/rbitcoin-sv2/src/transport.rs new file mode 100644 index 000000000..bcb54721f --- /dev/null +++ b/crates/rbitcoin-sv2/src/transport.rs @@ -0,0 +1,144 @@ +//! Noise_NX transport over one TCP stream: handshake, then encrypted SV2 frames. + +use binary_sv2::{GetSize, Serialize}; +use codec_sv2::{ + Decoded, Decrypted, Handshake, MessageFrame, NoiseDecoder, NoiseEncoder, TransportDecryptState, + TransportEncryptState, +}; +use noise_sv2::{Initiator, Responder}; +use std::io; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +/// One decrypted SV2 frame: header message type and owned payload. +pub struct Frame { + pub msg_type: u8, + pub payload: Vec, +} + +pub(crate) struct NoiseConn { + stream: TcpStream, + encoder: NoiseEncoder, + decoder: NoiseDecoder, + tx: TransportEncryptState, + // `next_transport_frame` consumes the state; a failed round leaves `None` + // and the connection must close (codec_sv2 nonce rule). + rx: Option, +} + +fn codec_err(e: codec_sv2::Error) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, format!("sv2 codec: {e:?}")) +} + +impl NoiseConn { + pub(crate) async fn accept( + mut stream: TcpStream, + responder: Box, + ) -> io::Result { + let mut decoder = NoiseDecoder::new(); + let mut encoder = NoiseEncoder::new(); + let first = loop { + match decoder + .next_handshake_frame::() + .map_err(codec_err)? + { + Decoded::Frame(m) => break m, + Decoded::Incomplete(_) => stream.read_exact(decoder.writable()).await?, + }; + }; + let re_pub = first + .payload() + .try_into() + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "sv2: handshake size"))?; + let (reply, transport) = Handshake::responder(responder) + .step_1(re_pub) + .map_err(codec_err)?; + stream + .write_all(encoder.encode_handshake(reply).as_ref()) + .await?; + let (tx, rx) = transport.split(); + Ok(Self { + stream, + encoder, + decoder, + tx, + rx: Some(rx), + }) + } + + pub(crate) async fn connect( + mut stream: TcpStream, + initiator: Box, + ) -> io::Result { + let mut decoder = NoiseDecoder::new(); + let mut encoder = NoiseEncoder::new(); + let (first, sent) = Handshake::initiator(initiator) + .step_0() + .map_err(codec_err)?; + stream + .write_all(encoder.encode_handshake(first).as_ref()) + .await?; + let reply = loop { + match decoder + .next_handshake_frame::() + .map_err(codec_err)? + { + Decoded::Frame(m) => break m, + Decoded::Incomplete(_) => stream.read_exact(decoder.writable()).await?, + }; + }; + let reply = reply + .payload() + .try_into() + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "sv2: handshake size"))?; + let (tx, rx) = sent.step_2(reply).map_err(codec_err)?.split(); + Ok(Self { + stream, + encoder, + decoder, + tx, + rx: Some(rx), + }) + } + + pub(crate) async fn send( + &mut self, + msg_type: u8, + msg: T, + ) -> io::Result<()> { + let frame = MessageFrame::from_message(msg, msg_type, 0, false).map_err(|e| { + io::Error::new(io::ErrorKind::InvalidInput, format!("sv2 frame: {e:?}")) + })?; + let bytes = self + .encoder + .encode_transport(frame, &mut self.tx) + .map_err(codec_err)?; + self.stream.write_all(bytes.as_ref()).await + } + + pub(crate) async fn recv(&mut self) -> io::Result { + loop { + let state = self + .rx + .take() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "sv2: decrypt failed"))?; + match self + .decoder + .next_transport_frame(state) + .map_err(codec_err)? + { + Decrypted::Frame(mut frame, state) => { + self.rx = Some(state); + return Ok(Frame { + msg_type: frame.header().msg_type(), + payload: frame.payload().to_vec(), + }); + } + Decrypted::Incomplete(_, state) => { + self.rx = Some(state); + self.stream.read_exact(self.decoder.writable()).await?; + } + } + } + } +} diff --git a/docs/CRATES.md b/docs/CRATES.md index 7618ed229..a63ffaf83 100644 --- a/docs/CRATES.md +++ b/docs/CRATES.md @@ -16,6 +16,7 @@ shared libraries through storage and runtime crates to composition and tools; | `rbitcoin-rpc` | Core-class JSON-RPC subset | | `rbitcoin-electrum` | Electrum TCP server | | `rbitcoin-esplora` | Esplora REST server | +| `rbitcoin-sv2` | Stratum v2 Template Distribution Protocol server (Noise over TCP) | | `rbitcoin-node` | Product binary and process composition | | `rbitcoin-cli` | RPC client binary | | `rbitcoin-test` | High-level scenario and integration-test harness | diff --git a/docs/sv2-template-provider.md b/docs/sv2-template-provider.md index 078607bd8..45a9a2568 100644 --- a/docs/sv2-template-provider.md +++ b/docs/sv2-template-provider.md @@ -181,13 +181,16 @@ Ships the listener, bootstrap, tip push, transaction data, and in-crate test initiator; success, bad-flags, bad-protocol, and (cap + 1)th-connection cases. - **Green:** `crates/rbitcoin-sv2` (workspace member) with the wire crates - pinned to the Step 0 set; authority-keypair config, listener task, + pinned to the Step 0 set minus `parsers_sv2` (known TDP / common types + decode with `binary_sv2::from_bytes`); authority-keypair config, listener task, per-connection session task driving the `codec_sv2` handshake then the common-message branch; session-cap semaphore on accept. Add the `rbitcoin-sv2` row to [`CRATES.md`](./CRATES.md) in this commit ([`README.md`](./README.md) rule: row with the new file). -- **Refactor:** session state as an enum (`Handshake`, - `AwaitingConstraints`, `Active`), not nested ifs. +- **Refactor:** session state as an enum (`AwaitingSetup`, + `AwaitingConstraints`, later `Active`), not nested ifs. The Noise + handshake is a typed prologue (`codec_sv2::Handshake` consumes its + state), not a phase. - **Verify:** `cargo test -p rbitcoin-sv2 setup_` ### B2 — Merkle path helper in consensus From b4106cfb5b2f053840af4e799b629dc5bb8284d7 Mon Sep 17 00:00:00 2001 From: Gary Krause Date: Tue, 29 Sep 2026 10:11:19 -0400 Subject: [PATCH 02/31] store: merkle branch helper shared by query and the TP 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 --- crates/rbitcoin-query/src/reconstruct.rs | 29 +------- crates/rbitcoin-store/src/integrity.rs | 67 ++++++++++++++++--- crates/rbitcoin-store/src/lib.rs | 4 +- crates/rbitcoin-store/tests/public_surface.rs | 19 +++--- docs/sv2-template-provider.md | 37 +++++----- 5 files changed, 93 insertions(+), 63 deletions(-) diff --git a/crates/rbitcoin-query/src/reconstruct.rs b/crates/rbitcoin-query/src/reconstruct.rs index 29e27ee89..0c8775952 100644 --- a/crates/rbitcoin-query/src/reconstruct.rs +++ b/crates/rbitcoin-query/src/reconstruct.rs @@ -148,42 +148,15 @@ impl Query { } pub fn merkle_proof(&self, height: Height, txid: &[u8; 32]) -> Result { - use bitcoin::hashes::{sha256d, Hash as _}; - let txids = self.block_txids(height)?; let pos = txids .iter() .position(|t| t == txid) .ok_or(StoreError::NotFound)?; - let mut branch = Vec::new(); - let mut idx = pos; - let mut layer: Vec<[u8; 32]> = txids; - while layer.len() > 1 { - if layer.len() % 2 == 1 { - layer.push(*layer.last().unwrap()); - } - let sibling = if idx % 2 == 0 { - layer[idx + 1] - } else { - layer[idx - 1] - }; - branch.push(sibling); - let mut next = Vec::with_capacity(layer.len() / 2); - let mut i = 0; - while i < layer.len() { - let mut buf = [0u8; 64]; - buf[0..32].copy_from_slice(&layer[i]); - buf[32..64].copy_from_slice(&layer[i + 1]); - next.push(sha256d::Hash::hash(&buf).to_byte_array()); - i += 2; - } - layer = next; - idx /= 2; - } Ok(MerkleProof { block_height: height.0, pos, - merkle: branch, + merkle: rbitcoin_store::merkle_branch(&txids, pos), }) } diff --git a/crates/rbitcoin-store/src/integrity.rs b/crates/rbitcoin-store/src/integrity.rs index b91ac0ae7..92104c4a9 100644 --- a/crates/rbitcoin-store/src/integrity.rs +++ b/crates/rbitcoin-store/src/integrity.rs @@ -134,20 +134,37 @@ pub fn merkle_root_mutated(leaves: &[[u8; 32]]) -> ([u8; 32], bool) { let mut level: Vec<[u8; 32]> = leaves.to_vec(); while level.len() > 1 { mutated |= level.chunks_exact(2).any(|pair| pair[0] == pair[1]); - if level.len() % 2 == 1 { - if let Some(last) = level.last().copied() { - level.push(last); - } - } - let mut next = Vec::with_capacity(level.len() / 2); - for pair in level.chunks_exact(2) { - next.push(hash256_concat(&pair[0], &pair[1])); - } - level = next; + level = merkle_parent_level(level); } (level[0], mutated) } +/// Sibling hashes from `leaves[index]` up to the root, deepest first. Empty for +/// one leaf. Folding the leaf with them (left when the index bit is 0) +/// reproduces [`merkle_root_from_txids`]. The leaf's own value feeds no entry. +pub fn merkle_branch(leaves: &[[u8; 32]], mut index: usize) -> Vec<[u8; 32]> { + let mut branch = Vec::new(); + let mut level: Vec<[u8; 32]> = leaves.to_vec(); + while level.len() > 1 { + let sibling = (index ^ 1).min(level.len() - 1); + branch.push(level[sibling]); + level = merkle_parent_level(level); + index /= 2; + } + branch +} + +// Bitcoin pairs an odd last node with itself. +fn merkle_parent_level(mut level: Vec<[u8; 32]>) -> Vec<[u8; 32]> { + if level.len() % 2 == 1 { + level.push(level[level.len() - 1]); + } + level + .chunks_exact(2) + .map(|pair| hash256_concat(&pair[0], &pair[1])) + .collect() +} + fn hash256_concat(a: &[u8; 32], b: &[u8; 32]) -> [u8; 32] { let mut eng = sha256::HashEngine::default(); eng.input(a); @@ -568,6 +585,36 @@ mod tests { assert_eq!(merkle_root_mutated(&[]), ([0u8; 32], false)); } + #[test] + fn merkle_path_folds_to_root_at_every_index() { + let (a, b, c) = ([1u8; 32], [2u8; 32], [3u8; 32]); + assert!(merkle_branch(&[a], 0).is_empty()); + assert_eq!(merkle_branch(&[a, b], 0), vec![b]); + assert_eq!( + merkle_branch(&[a, b, c], 0), + vec![b, hash256_concat(&c, &c)] + ); + assert_eq!( + merkle_branch(&[a, b, c], 2), + vec![c, hash256_concat(&a, &b)] + ); + for n in 1..=9u8 { + let leaves: Vec<[u8; 32]> = (0..n).map(|i| [i + 10; 32]).collect(); + let root = merkle_root_from_txids(&leaves); + for (index, leaf) in leaves.iter().enumerate() { + let mut h = *leaf; + for (depth, sib) in merkle_branch(&leaves, index).iter().enumerate() { + h = if (index >> depth) & 1 == 0 { + hash256_concat(&h, sib) + } else { + hash256_concat(sib, &h) + }; + } + assert_eq!(h, root, "n={n} index={index}"); + } + } + } + #[test] fn clean_header_chain_revalidate_no_shrink() { let dir = tmp(); diff --git a/crates/rbitcoin-store/src/lib.rs b/crates/rbitcoin-store/src/lib.rs index 2076e20e5..0aa9d5f53 100644 --- a/crates/rbitcoin-store/src/lib.rs +++ b/crates/rbitcoin-store/src/lib.rs @@ -81,7 +81,9 @@ pub(crate) use idx_body_pipeline::run_idx_body_pipeline; pub use idx_body_pipeline::{BodyMode as IdxBodyMode, IdxBodyJob}; pub use index_build_uring::{read_index_window, IndexBlock, IndexHeight, IndexWindow}; pub use int_map::{FkMap, FkSet, U32Map, U64IdentityHasher, U64Map, U64Set}; -pub use integrity::{merkle_root_from_txids, merkle_root_mutated, VERIFY_TIP_BLOCKS}; +pub use integrity::{ + merkle_branch, merkle_root_from_txids, merkle_root_mutated, VERIFY_TIP_BLOCKS, +}; pub use io_backend::{ReadIoBackend, WriteIoBackend}; pub use point_table::PointRecord; pub use scripthash::{ diff --git a/crates/rbitcoin-store/tests/public_surface.rs b/crates/rbitcoin-store/tests/public_surface.rs index 8b9405660..20286c24e 100644 --- a/crates/rbitcoin-store/tests/public_surface.rs +++ b/crates/rbitcoin-store/tests/public_surface.rs @@ -11,15 +11,15 @@ use rbitcoin_store::{ encode_txout_meta_and_outs, encode_unspent_output_into_secret, ensure_nofile_budget, free_gib_label, is_probe_exhausted_error, is_store_corrupt_display, leftover_probe_diag_ready, leftover_probe_diag_recorded, list_materialize_claims, list_runs, load_tweak_wave, - materialize_sh_unsorted_from_class_a, merkle_root_from_txids, merkle_root_mutated, - next_run_path, output_flags, script_hash, sh_heads_insert_capped, spend_ann_backend, - spend_meta_backend, spent_abs, unsorted_collect_workers, unsorted_done_last_fk, - unsorted_pack_workers, unsorted_shard_dir, write_sorted_run, BlockQueue, ColdProgress, FkMap, - FkSet, HeadOpenOpts, HeadResizeSizeSnapshot, HeadScale, HeaderRecord, HeightFence, IdxBodyJob, - IdxBodyMode, InputRecord, OutputRecord, PackedCreate, PointRecord, QueuedBlockMeta, - ReadIoBackend, ScriptHashRecord, ShHeadValue, SpTweaksTable, Store, StoreError, StoreLayout, - StoreSecret, TakenRaw, TxRecord, U32Map, U64IdentityHasher, U64Map, U64Set, WriteIoBackend, - INCLUDE_HWM_NAME, SH_HEADS_CAP, + materialize_sh_unsorted_from_class_a, merkle_branch, merkle_root_from_txids, + merkle_root_mutated, next_run_path, output_flags, script_hash, sh_heads_insert_capped, + spend_ann_backend, spend_meta_backend, spent_abs, unsorted_collect_workers, + unsorted_done_last_fk, unsorted_pack_workers, unsorted_shard_dir, write_sorted_run, BlockQueue, + ColdProgress, FkMap, FkSet, HeadOpenOpts, HeadResizeSizeSnapshot, HeadScale, HeaderRecord, + HeightFence, IdxBodyJob, IdxBodyMode, InputRecord, OutputRecord, PackedCreate, PointRecord, + QueuedBlockMeta, ReadIoBackend, ScriptHashRecord, ShHeadValue, SpTweaksTable, Store, + StoreError, StoreLayout, StoreSecret, TakenRaw, TxRecord, U32Map, U64IdentityHasher, U64Map, + U64Set, WriteIoBackend, INCLUDE_HWM_NAME, SH_HEADS_CAP, }; #[test] @@ -63,6 +63,7 @@ fn crate_root_exports_cross_crate_names() { let _ = leftover_probe_diag_ready; let _ = leftover_probe_diag_recorded; let _ = load_tweak_wave; + let _ = merkle_branch; let _ = merkle_root_from_txids; let _ = merkle_root_mutated; let _ = next_run_path; diff --git a/docs/sv2-template-provider.md b/docs/sv2-template-provider.md index 45a9a2568..4e88bf9b2 100644 --- a/docs/sv2-template-provider.md +++ b/docs/sv2-template-provider.md @@ -193,19 +193,26 @@ Ships the listener, bootstrap, tip push, transaction data, and state), not a phase. - **Verify:** `cargo test -p rbitcoin-sv2 setup_` -### B2 — Merkle path helper in consensus - -- **Contract:** `coinbase_merkle_path(txids)` returns the leftmost-branch - hashes deepest-first; folding them with the coinbase txid reproduces - `merkle_root_bytes` for the same list. Edge cases: single tx (empty - path), odd counts at every level. -- **Red:** `cargo test -p rbitcoin-consensus merkle_path_` — small known - vectors. -- **Green:** helper next to `merkle_root_bytes` - (`crates/rbitcoin-consensus/src/block/mod.rs`). -- **Refactor:** share the level-pairing loop with the root computation if - it dedupes without obscuring. -- **Verify:** `cargo test -p rbitcoin-consensus merkle_path_` +### B2 — Merkle path helper next to the root + +- **Contract:** `merkle_branch(leaves, index)` returns the sibling hashes + from `leaves[index]` to the root, deepest-first; folding them with the + leaf reproduces `merkle_root_from_txids` for the same list. The coinbase + path is `index = 0` (the leaf's own value feeds no entry, so the builder + passes a placeholder). Edge cases: single tx (empty path), odd counts at + every level. +- **Red:** `cargo test -p rbitcoin-store --lib merkle_path_` — small known + vectors plus a fold at every index for 1..=9 leaves (pure arithmetic, no + session reaches it before B3). +- **Green:** helper next to `merkle_root_from_txids` + (`crates/rbitcoin-store/src/integrity.rs`). The root's owner is the store + (consensus `merkle_root_bytes` only wraps it), and + `rbitcoin-query` `merkle_proof` (Electrum `get_merkle`, Esplora + `merkle-proof`) had its own inline branch loop; one owner for both. +- **Refactor:** root and branch share one level-pairing step; + `Query::merkle_proof` calls `merkle_branch`. +- **Verify:** `cargo test -p rbitcoin-store --lib merkle_`, Electrum / + Esplora merkle journeys ### B3 — Template builder with TDP coinbase @@ -214,7 +221,7 @@ Ships the listener, bootstrap, tip push, transaction data, and ([Constraints](#constraints-all-plans)); `coinbase_prefix` is the BIP34 height push; `value_remaining` = subsidy + Σ selected fees (from the selection, not a re-read); outputs = witness commitment last; - `merkle_path` from B2; the record carries the serialized non-coinbase + `merkle_path` from B2's `merkle_branch`; the record carries the serialized non-coinbase txs in selection order. - **Red:** `cargo test -p rbitcoin-sv2 template_` — synthetic mempool (reuse `rbitcoin-mempool` accept fixtures): weight bound at the reserved @@ -344,7 +351,7 @@ when fees rise enough to matter, throttled. Requires Plan B. ## Test budget -Units in `rbitcoin-mempool` (budgeted selection), `rbitcoin-consensus` +Units in `rbitcoin-mempool` (budgeted selection), `rbitcoin-store` (merkle path), and `rbitcoin-sv2` (builder, throttle, gate). **One** regtest integration journey in `rbitcoin-test`, opened in B4 and extended by B5–B7 and C1 — one node open, per [`TESTING.md`](../TESTING.md) budgets. From 2fc073d8eef8563d62152c04c191e0f6beee5f5a Mon Sep 17 00:00:00 2001 From: Gary Krause Date: Tue, 29 Sep 2026 10:22:13 -0400 Subject: [PATCH 03/31] sv2: build and send NewTemplate on CoinbaseOutputConstraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 15 +++ crates/rbitcoin-net/src/lib.rs | 2 +- crates/rbitcoin-sv2/Cargo.toml | 8 ++ crates/rbitcoin-sv2/src/lib.rs | 12 +- crates/rbitcoin-sv2/src/listener_tests.rs | 3 + crates/rbitcoin-sv2/src/session.rs | 54 +++++++- crates/rbitcoin-sv2/src/template.rs | 91 +++++++++++++ crates/rbitcoin-sv2/src/template_tests.rs | 155 ++++++++++++++++++++++ crates/rbitcoin-sv2/src/test_chain.rs | 43 ++++++ crates/rbitcoin-sv2/src/testutil.rs | 17 +++ docs/sv2-template-provider.md | 37 +++--- 11 files changed, 418 insertions(+), 19 deletions(-) create mode 100644 crates/rbitcoin-sv2/src/template.rs create mode 100644 crates/rbitcoin-sv2/src/template_tests.rs create mode 100644 crates/rbitcoin-sv2/src/test_chain.rs diff --git a/Cargo.lock b/Cargo.lock index a61dbd1e2..ef7e26b8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1081,7 +1081,13 @@ dependencies = [ "codec_sv2", "common_messages_sv2", "noise_sv2", + "rbitcoin-consensus", "rbitcoin-log", + "rbitcoin-net", + "rbitcoin-primitives", + "rbitcoin-query", + "rbitcoin-store", + "template_distribution_sv2", "tokio", ] @@ -1321,6 +1327,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "template_distribution_sv2" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859d37310c5802cfafdc1423c1e8fb0fec287a95ee19ac648424f3c811e98152" +dependencies = [ + "binary_sv2", +] + [[package]] name = "tokio" version = "1.53.1" diff --git a/crates/rbitcoin-net/src/lib.rs b/crates/rbitcoin-net/src/lib.rs index ea4d44f77..46d4f7de9 100644 --- a/crates/rbitcoin-net/src/lib.rs +++ b/crates/rbitcoin-net/src/lib.rs @@ -66,7 +66,7 @@ pub use peers::{ }; pub use perf_meter::RequestMeter; pub(crate) use rbitcoin_mempool::MempoolGraphStats; -pub use rbitcoin_mempool::{AcceptError, Selected}; +pub use rbitcoin_mempool::{AcceptError, SelectBudget, Selected}; pub use reactor::BlockingRegion; pub use seeds::{ default_port, default_rpc_port, dns_seeds, fixed_seed_hosts, resolve_all_seeds, diff --git a/crates/rbitcoin-sv2/Cargo.toml b/crates/rbitcoin-sv2/Cargo.toml index 4997ce2da..3cb25d24c 100644 --- a/crates/rbitcoin-sv2/Cargo.toml +++ b/crates/rbitcoin-sv2/Cargo.toml @@ -9,6 +9,9 @@ description = "Stratum v2 Template Distribution Protocol server (Noise over TCP) [dependencies] rbitcoin-log = { workspace = true } +rbitcoin-consensus = { workspace = true } +rbitcoin-net = { workspace = true } +rbitcoin-store = { workspace = true } bitcoin = { workspace = true } tokio = { workspace = true } # Stratum v2 wire crates, pinned to the Step 0 spike set @@ -18,6 +21,11 @@ noise_sv2 = "=2.0.0" codec_sv2 = { version = "=7.0.0", features = ["noise_sv2"] } binary_sv2 = "=7.0.0" common_messages_sv2 = "=9.0.0" +template_distribution_sv2 = "=7.0.0" + +[dev-dependencies] +rbitcoin-primitives = { workspace = true } +rbitcoin-query = { workspace = true } [lints] workspace = true diff --git a/crates/rbitcoin-sv2/src/lib.rs b/crates/rbitcoin-sv2/src/lib.rs index 78033ae85..483fe9ce3 100644 --- a/crates/rbitcoin-sv2/src/lib.rs +++ b/crates/rbitcoin-sv2/src/lib.rs @@ -4,10 +4,12 @@ //! `docs/sv2-template-provider.md`. mod session; +mod template; pub mod testutil; mod transport; use bitcoin::secp256k1::{Keypair, Secp256k1}; +use rbitcoin_net::ChainHub; use std::io; use std::net::SocketAddr; use std::sync::{Arc, Mutex}; @@ -25,6 +27,8 @@ pub const MAX_SESSIONS: usize = 8; pub struct Sv2TpConfig { pub listen: SocketAddr, + /// Tip, params, and the attached mempool the templates are built from. + pub chain: Arc, /// Authority secret key; clients pin its x-only public key. pub authority_secret: [u8; 32], /// Validity of the per-connection Noise certificate signed by the authority. @@ -64,6 +68,7 @@ pub async fn run_sv2_tp(config: Sv2TpConfig) -> io::Result { let authority_pubkey = keypair.x_only_public_key().0.serialize(); let authority_secret = config.authority_secret; let cert_validity = config.cert_validity; + let chain = config.chain; let listener = TcpListener::bind(config.listen).await?; let local_addr = listener.local_addr()?; let slots = Arc::new(Semaphore::new(MAX_SESSIONS)); @@ -97,9 +102,10 @@ pub async fn run_sv2_tp(config: Sv2TpConfig) -> io::Result { } }; rbitcoin_log::info!("sv2: connect {peer}"); + let chain = Arc::clone(&chain); let h = tokio::spawn(async move { let _slot = slot; - match session::serve(stream, responder).await { + match session::serve(stream, responder, chain).await { Ok(()) => rbitcoin_log::info!("sv2: disconnect {peer}"), Err(e) => rbitcoin_log::info!("sv2: disconnect {peer} ({e})"), } @@ -120,3 +126,7 @@ pub async fn run_sv2_tp(config: Sv2TpConfig) -> io::Result { #[cfg(test)] mod listener_tests; +#[cfg(test)] +mod template_tests; +#[cfg(test)] +mod test_chain; diff --git a/crates/rbitcoin-sv2/src/listener_tests.rs b/crates/rbitcoin-sv2/src/listener_tests.rs index 4198b55f7..0e92a4589 100644 --- a/crates/rbitcoin-sv2/src/listener_tests.rs +++ b/crates/rbitcoin-sv2/src/listener_tests.rs @@ -1,3 +1,4 @@ +use crate::test_chain::padded_chain; use crate::testutil::TpClient; use crate::{run_sv2_tp, Sv2TpConfig, MAX_SESSIONS}; use common_messages_sv2::{ @@ -35,8 +36,10 @@ async fn expect_error(c: &mut TpClient, flags: u32, code: &str) { #[tokio::test] async fn setup_connection_success_errors_and_session_cap() { + let tc = padded_chain("sv2-listener", 0); let tp = run_sv2_tp(Sv2TpConfig { listen: "127.0.0.1:0".parse().unwrap(), + chain: tc.chain.clone(), authority_secret: [7; 32], cert_validity: Duration::from_secs(3600), }) diff --git a/crates/rbitcoin-sv2/src/session.rs b/crates/rbitcoin-sv2/src/session.rs index 98bbb8559..02e7132cf 100644 --- a/crates/rbitcoin-sv2/src/session.rs +++ b/crates/rbitcoin-sv2/src/session.rs @@ -1,5 +1,6 @@ //! One TDP session: Noise handshake, `SetupConnection`, then TDP messages. +use crate::template; use crate::transport::{Frame, NoiseConn}; use binary_sv2::Str0255; use common_messages_sv2::{ @@ -10,14 +11,21 @@ use common_messages_sv2::{ MESSAGE_TYPE_SETUP_CONNECTION_ERROR, MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, }; use noise_sv2::Responder; +use rbitcoin_net::{BlockingRegion, ChainHub}; use std::io; +use std::sync::Arc; +use template_distribution_sv2::{ + CoinbaseOutputConstraints, MESSAGE_TYPE_COINBASE_OUTPUT_CONSTRAINTS, MESSAGE_TYPE_NEW_TEMPLATE, +}; use tokio::net::TcpStream; const TDP_VERSION: u16 = 2; +#[derive(Clone, Copy)] enum Phase { AwaitingSetup, AwaitingConstraints, + Active, } enum Next { @@ -25,14 +33,28 @@ enum Next { Close, } -pub(crate) async fn serve(stream: TcpStream, responder: Box) -> io::Result<()> { +pub(crate) async fn serve( + stream: TcpStream, + responder: Box, + chain: Arc, +) -> io::Result<()> { let mut conn = NoiseConn::accept(stream, responder).await?; let mut phase = Phase::AwaitingSetup; + let mut last_template_id = 0u64; loop { let frame = conn.recv().await?; let next = match phase { Phase::AwaitingSetup => on_setup(&mut conn, frame).await?, - Phase::AwaitingConstraints => Next::Continue(Phase::AwaitingConstraints), + Phase::AwaitingConstraints | Phase::Active + if frame.msg_type == MESSAGE_TYPE_COINBASE_OUTPUT_CONSTRAINTS => + { + last_template_id += 1; + on_constraints(&mut conn, &chain, frame, last_template_id).await? + } + Phase::AwaitingConstraints | Phase::Active => { + rbitcoin_log::info!("sv2: ignoring message {:#x}", frame.msg_type); + Next::Continue(phase) + } }; phase = match next { Next::Continue(p) => p, @@ -83,3 +105,31 @@ async fn on_setup(conn: &mut NoiseConn, mut frame: Frame) -> io::Result { .await?; Ok(Next::Continue(Phase::AwaitingConstraints)) } + +async fn on_constraints( + conn: &mut NoiseConn, + chain: &Arc, + mut frame: Frame, + template_id: u64, +) -> io::Result { + let Ok(c) = binary_sv2::from_bytes::(&mut frame.payload) else { + rbitcoin_log::info!("sv2: undecodable CoinbaseOutputConstraints"); + return Ok(Next::Close); + }; + let (size, sigops) = ( + c.coinbase_output_max_additional_size, + c.coinbase_output_max_additional_sigops, + ); + let chain = Arc::clone(chain); + let t = tokio::task::spawn_blocking(move || { + let _g = BlockingRegion::enter(); + template::build(&chain, size, sigops) + }) + .await + .map_err(io::Error::other)?; + let msg = t + .to_message(template_id, true) + .map_err(|e| io::Error::other(format!("sv2 NewTemplate: {e:?}")))?; + conn.send(MESSAGE_TYPE_NEW_TEMPLATE, msg).await?; + Ok(Next::Continue(Phase::Active)) +} diff --git a/crates/rbitcoin-sv2/src/template.rs b/crates/rbitcoin-sv2/src/template.rs new file mode 100644 index 000000000..fdf311105 --- /dev/null +++ b/crates/rbitcoin-sv2/src/template.rs @@ -0,0 +1,91 @@ +//! One TDP template: budgeted mempool selection plus the coinbase split +//! (sv2-spec 07 §7.1–7.2). + +use binary_sv2::{Seq0255, B0255, B064K, U256}; +use bitcoin::consensus::encode::serialize; +use bitcoin::hashes::Hash; +use bitcoin::{Amount, ScriptBuf, TxOut}; +use rbitcoin_consensus::{ + bip34_height_script, block_subsidy, witness_commitment_script, MAX_BLOCK_WEIGHT, +}; +use rbitcoin_net::{ChainHub, SelectBudget}; +use template_distribution_sv2::NewTemplate; + +/// sv2-spec 07 §7.1: coinbase weight outside the client's additional outputs, +/// and the floor on the whole reserve. +const COINBASE_BASE_WU: u64 = 1168; +const MIN_COINBASE_RESERVE_WU: u64 = 2000; + +pub(crate) struct Template { + pub version: u32, + pub coinbase_prefix: Vec, + pub value_remaining: u64, + /// The witness commitment output, serialized with no count prefix. + pub coinbase_outputs: Vec, + pub merkle_path: Vec<[u8; 32]>, +} + +impl Template { + /// Coinbase fields not carried by the record are fixed: version 2, one + /// final input, zero locktime. + pub fn to_message( + &self, + template_id: u64, + future_template: bool, + ) -> Result, binary_sv2::Error> { + Ok(NewTemplate { + template_id, + future_template, + version: self.version, + coinbase_tx_version: 2, + coinbase_prefix: B0255::try_from(&self.coinbase_prefix[..])?, + coinbase_tx_input_sequence: u32::MAX, + coinbase_tx_value_remaining: self.value_remaining, + coinbase_tx_outputs_count: 1, + coinbase_tx_outputs: B064K::try_from(&self.coinbase_outputs[..])?, + coinbase_tx_locktime: 0, + merkle_path: Seq0255::new(self.merkle_path.iter().map(U256::from).collect())?, + }) + } +} + +/// Template on the current tip for one client's coinbase constraints. The +/// client's sigops replace the default reserve (Core `BlockAssembler`). +/// Takes the mempool lock: blocking region only. +pub(crate) fn build( + chain: &ChainHub, + max_additional_size: u32, + max_additional_sigops: u16, +) -> Template { + let reserve = + (COINBASE_BASE_WU + 4 * u64::from(max_additional_size)).max(MIN_COINBASE_RESERVE_WU); + let budget = SelectBudget { + max_weight_wu: MAX_BLOCK_WEIGHT.saturating_sub(reserve), + reserved_sigops: u64::from(max_additional_sigops), + min_sat_kvb: chain.block_min_tx_fee_sat_kvb(), + }; + let height = chain.query.tip_height().map_or(0, |h| h.0 + 1); + let selected = chain + .mempool() + .map(|m| m.select_block_template(budget)) + .unwrap_or_default(); + let fees: u64 = selected.iter().map(|(_, s)| s.fee_sat).sum(); + let commitment = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::from_bytes(witness_commitment_script( + selected + .iter() + .map(|(tx, _)| tx.compute_wtxid().to_byte_array()), + &[0u8; 32], + )), + }; + let mut leaves = vec![[0u8; 32]]; + leaves.extend(selected.iter().map(|(_, s)| s.txid.to_byte_array())); + Template { + version: chain.gbt_block_version() as u32, + coinbase_prefix: bip34_height_script(height), + value_remaining: (block_subsidy(height, &chain.params) as u64).saturating_add(fees), + coinbase_outputs: serialize(&commitment), + merkle_path: rbitcoin_store::merkle_branch(&leaves, 0), + } +} diff --git a/crates/rbitcoin-sv2/src/template_tests.rs b/crates/rbitcoin-sv2/src/template_tests.rs new file mode 100644 index 000000000..a3a7c2a11 --- /dev/null +++ b/crates/rbitcoin-sv2/src/template_tests.rs @@ -0,0 +1,155 @@ +use crate::test_chain::{padded_chain, TestChain}; +use crate::testutil::TpClient; +use crate::{run_sv2_tp, Sv2TpConfig}; +use bitcoin::consensus::encode::serialize; +use bitcoin::hashes::{sha256d, Hash}; +use bitcoin::{ + absolute::LockTime, transaction::Version, Amount, OutPoint, ScriptBuf, Sequence, Transaction, + TxIn, TxOut, Txid, Witness, +}; +use rbitcoin_consensus::{bip34_height_script, block_subsidy, witness_commitment_script}; +use rbitcoin_store::merkle_root_from_txids; +use std::sync::Arc; +use std::time::Duration; +use template_distribution_sv2::{NewTemplate, MESSAGE_TYPE_NEW_TEMPLATE}; + +const MAX_BLOCK_WEIGHT: u64 = 4_000_000; +const OP_TRUE: u8 = 0x51; +const OP_CHECKSIG: u8 = 0xac; + +fn spend(coinbase: Txid, fee: u64, script_pubkey: ScriptBuf) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: coinbase, + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(50_0000_0000 - fee), + script_pubkey, + }], + } +} + +/// Coinbase is leaf 0, so every fold step hashes the running value on the left. +fn fold_coinbase_path(leaf: [u8; 32], path: &[[u8; 32]]) -> [u8; 32] { + path.iter().fold(leaf, |acc, sibling| { + let mut buf = [0u8; 64]; + buf[..32].copy_from_slice(&acc); + buf[32..].copy_from_slice(sibling); + sha256d::Hash::hash(&buf).to_byte_array() + }) +} + +async fn expect_template(c: &mut TpClient, tc: &TestChain, txs: &[&Transaction]) -> u64 { + let mut f = tokio::time::timeout(Duration::from_secs(10), c.recv()) + .await + .expect("NewTemplate in time") + .expect("NewTemplate"); + assert_eq!(f.msg_type, MESSAGE_TYPE_NEW_TEMPLATE); + let t: NewTemplate = binary_sv2::from_bytes(&mut f.payload).expect("decode"); + let next_h = tc.chain.query.tip_height().expect("tip").0 + 1; + let fees: u64 = txs + .iter() + .map(|tx| 50_0000_0000 - tx.output[0].value.to_sat()) + .sum(); + + assert!(t.future_template); + assert_eq!(t.version, tc.chain.gbt_block_version() as u32); + assert_eq!( + ( + t.coinbase_tx_version, + t.coinbase_tx_input_sequence, + t.coinbase_tx_locktime + ), + (2, u32::MAX, 0) + ); + assert_eq!(t.coinbase_prefix.as_ref(), bip34_height_script(next_h)); + assert_eq!( + t.coinbase_tx_value_remaining, + block_subsidy(next_h, &tc.chain.params) as u64 + fees + ); + let commitment = TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::from_bytes(witness_commitment_script( + txs.iter().map(|tx| tx.compute_wtxid().to_byte_array()), + &[0u8; 32], + )), + }; + assert_eq!(t.coinbase_tx_outputs_count, 1); + assert_eq!(t.coinbase_tx_outputs.as_ref(), serialize(&commitment)); + + let path: Vec<[u8; 32]> = t + .merkle_path + .iter() + .map(|h| h.as_ref().try_into().expect("32-byte hash")) + .collect(); + let coinbase_leaf = [0x11; 32]; + let mut leaves = vec![coinbase_leaf]; + leaves.extend(txs.iter().map(|tx| tx.compute_txid().to_byte_array())); + assert_eq!( + fold_coinbase_path(coinbase_leaf, &path), + merkle_root_from_txids(&leaves), + "merkle path must fold to the root over the selection order" + ); + t.template_id +} + +#[tokio::test(flavor = "multi_thread")] +async fn template_budget_fees_coinbase_and_merkle_path() { + let tc = padded_chain("sv2-template", 3); + let cheap = ScriptBuf::from_bytes(vec![OP_TRUE]); + let a = spend(tc.coinbases[0], 3_000, cheap.clone()); + let b = spend(tc.coinbases[1], 2_000, cheap); + // 4000 legacy sigops × 4 = 16_000 cost; Libre admits the script. + let heavy = spend( + tc.coinbases[2], + 20_000, + ScriptBuf::from_bytes(vec![OP_CHECKSIG; 4_000]), + ); + for tx in [&a, &b, &heavy] { + tc.mempool.accept_tx(tx).expect("mempool accept"); + } + + let tp = run_sv2_tp(Sv2TpConfig { + listen: "127.0.0.1:0".parse().unwrap(), + chain: Arc::clone(&tc.chain), + authority_secret: [7; 32], + cert_validity: Duration::from_secs(3600), + }) + .await + .expect("listen"); + let mut c = TpClient::connect(tp.local_addr, tp.authority_pubkey) + .await + .expect("handshake"); + c.setup_connection(2, 2, 2, 0).await.unwrap(); + c.recv().await.expect("setup reply"); + + c.coinbase_output_constraints(0, 0).await.unwrap(); + let mut last = expect_template(&mut c, &tc, &[&a, &b, &heavy]).await; + + // The client's sigops replace the reserve: 65_535 + 16_000 ≥ 80_000. + c.coinbase_output_constraints(0, u16::MAX).await.unwrap(); + let id = expect_template(&mut c, &tc, &[&a, &b]).await; + assert!(id > last, "template_id must increase"); + last = id; + + // Reserved weight 1168 + 4·size leaves exactly a + b, then 4 WU less. + let edge = MAX_BLOCK_WEIGHT - 1168 - a.weight().to_wu() - b.weight().to_wu(); + let size = u32::try_from(edge / 4).unwrap(); + c.coinbase_output_constraints(size, 0).await.unwrap(); + let id = expect_template(&mut c, &tc, &[&a, &b]).await; + assert!(id > last, "template_id must increase"); + last = id; + c.coinbase_output_constraints(size + 1, 0).await.unwrap(); + let id = expect_template(&mut c, &tc, &[&a]).await; + assert!(id > last, "template_id must increase"); + + tp.shutdown().await; +} diff --git a/crates/rbitcoin-sv2/src/test_chain.rs b/crates/rbitcoin-sv2/src/test_chain.rs new file mode 100644 index 000000000..4831b0ef2 --- /dev/null +++ b/crates/rbitcoin-sv2/src/test_chain.rs @@ -0,0 +1,43 @@ +use bitcoin::{Network, Txid}; +use rbitcoin_consensus::{accept_and_connect_block, pad_empty_from, ChainParams, Milestone}; +use rbitcoin_net::{ChainHub, MempoolHub}; +use rbitcoin_primitives::Height; +use rbitcoin_query::testutil::{tiny_query_labeled, TempDir}; +use std::sync::Arc; + +pub(crate) struct TestChain { + pub _dir: TempDir, + pub chain: Arc, + pub mempool: Arc, + /// Mature OP_TRUE coinbases, oldest first. + pub coinbases: Vec, +} + +/// Regtest chain padded to `100 + spendable` with an attached relaying mempool. +pub(crate) fn padded_chain(label: &str, spendable: u32) -> TestChain { + let (dir, q) = tiny_query_labeled(label); + let params = ChainParams::regtest(); + let genesis = bitcoin::blockdata::constants::genesis_block(Network::Regtest); + accept_and_connect_block(&q, ¶ms, Height::GENESIS, &genesis, Milestone::NONE).unwrap(); + let (_, _, coinbases) = pad_empty_from( + &q, + ¶ms, + genesis.block_hash(), + genesis.header.time, + 1, + 100 + spendable, + spendable, + ); + let chain = Arc::new(ChainHub::new(q, params, Milestone::NONE)); + let mp = dir.path().join("mempool"); + std::fs::create_dir_all(&mp).unwrap(); + let mempool = MempoolHub::open(&mp, Arc::clone(&chain.query)).unwrap(); + mempool.set_relay_enabled(true); + assert!(chain.attach_mempool(Arc::clone(&mempool)).is_ok()); + TestChain { + _dir: dir, + chain, + mempool, + coinbases, + } +} diff --git a/crates/rbitcoin-sv2/src/testutil.rs b/crates/rbitcoin-sv2/src/testutil.rs index 21176f05a..466899ad6 100644 --- a/crates/rbitcoin-sv2/src/testutil.rs +++ b/crates/rbitcoin-sv2/src/testutil.rs @@ -5,6 +5,9 @@ use binary_sv2::Str0255; use common_messages_sv2::{Protocol, SetupConnection, MESSAGE_TYPE_SETUP_CONNECTION}; use std::io; use std::net::SocketAddr; +use template_distribution_sv2::{ + CoinbaseOutputConstraints, MESSAGE_TYPE_COINBASE_OUTPUT_CONSTRAINTS, +}; use tokio::net::TcpStream; pub struct TpClient { @@ -48,6 +51,20 @@ impl TpClient { self.conn.send(MESSAGE_TYPE_SETUP_CONNECTION, msg).await } + pub async fn coinbase_output_constraints( + &mut self, + max_additional_size: u32, + max_additional_sigops: u16, + ) -> io::Result<()> { + let msg = CoinbaseOutputConstraints { + coinbase_output_max_additional_size: max_additional_size, + coinbase_output_max_additional_sigops: max_additional_sigops, + }; + self.conn + .send(MESSAGE_TYPE_COINBASE_OUTPUT_CONSTRAINTS, msg) + .await + } + pub async fn recv(&mut self) -> io::Result { self.conn.recv().await } diff --git a/docs/sv2-template-provider.md b/docs/sv2-template-provider.md index 4e88bf9b2..323dbf0f9 100644 --- a/docs/sv2-template-provider.md +++ b/docs/sv2-template-provider.md @@ -68,8 +68,8 @@ three unused subprotocol crates) if it adds nothing. ## Constraints (all plans) - New crate `crates/rbitcoin-sv2` (Plan B), service pattern of - electrum/esplora: depends on `rbitcoin-query` / `rbitcoin-net` / - `rbitcoin-mempool` / `rbitcoin-consensus`; wired in `rbitcoin-node` + electrum/esplora: depends on `rbitcoin-net` (`ChainHub`, `MempoolHub`) / + `rbitcoin-consensus` / `rbitcoin-store` (merkle); wired in `rbitcoin-node` `run.rs` behind flags. Nothing starts without `--sv2-tp-listen`. - `binary_sv2` byte-buffer types and `noise_sv2`'s `secp256k1` 0.28 stay inside `rbitcoin-sv2`; consensus decode uses the workspace @@ -214,21 +214,27 @@ Ships the listener, bootstrap, tip push, transaction data, and - **Verify:** `cargo test -p rbitcoin-store --lib merkle_`, Electrum / Esplora merkle journeys -### B3 — Template builder with TDP coinbase +### B3 — Template builder, NewTemplate on constraints -- **Contract:** `build(hub, tip, constraints) -> TemplateRecord` calls +- **Contract:** after setup, `CoinbaseOutputConstraints` makes the session + build in a blocking region and send `NewTemplate{future_template: true}` + with a strictly increasing `template_id`; a re-sent constraints message + rebuilds with the new budget. The build calls `MempoolHub::select_block_template` with the per-session budget ([Constraints](#constraints-all-plans)); `coinbase_prefix` is the BIP34 height push; `value_remaining` = subsidy + Σ selected fees (from the selection, not a re-read); outputs = witness commitment last; - `merkle_path` from B2's `merkle_branch`; the record carries the serialized non-coinbase - txs in selection order. -- **Red:** `cargo test -p rbitcoin-sv2 template_` — synthetic mempool - (reuse `rbitcoin-mempool` accept fixtures): weight bound at the reserved - edge, sigops at a large `max_additional_sigops`, fee sum, prefix bytes, - commitment, tx order. -- **Green:** builder module in `rbitcoin-sv2`; subsidy/params from - `rbitcoin-consensus`. + `merkle_path` from B2's `merkle_branch` over the selection order. +- **Red:** `cargo test -p rbitcoin-sv2 template_` — loopback test client + against a padded regtest `ChainHub` with an attached `MempoolHub` + (Libre policy admits a high-sigop output script): weight bound at the + reserved edge, sigops at a large `max_additional_sigops`, fee sum, + prefix bytes, commitment, tx order via the merkle fold. +- **Green:** builder module in `rbitcoin-sv2`; subsidy from + `rbitcoin-consensus`, version and min fee from `ChainHub`. The listener + config carries the `ChainHub`. Sending in this step keeps the builder + reachable from the shipped path (no test-only caller); tx retention + lands with its first reader in B5. - **Refactor:** none expected (commitment and selection already have one owner). - **Verify:** `cargo test -p rbitcoin-sv2 template_` @@ -247,8 +253,8 @@ Ships the listener, bootstrap, tip push, transaction data, and against the node tip and mempool, and SetNewPrevHash consistency. Gate predicate unit in `rbitcoin-sv2`. - **Green:** `run.rs` service start behind `--sv2-tp-listen` / - `--sv2-tp-authority-sec` / `--sv2-tp-cert-validity`; session loop calls - the builder on first constraints; per-session template map. + `--sv2-tp-authority-sec` / `--sv2-tp-cert-validity`; `SetNewPrevHash` + after the first B3 `NewTemplate`; sync gate before the first build. - **Refactor:** flag plumbing follows the `esplora_block_template` config pattern. - **Verify:** `cargo test -p rbitcoin-test sv2_tp_bootstrap`, @@ -263,7 +269,8 @@ Ships the listener, bootstrap, tip push, transaction data, and "template-id-not-found"}`. - **Red:** extend the B4 journey: request the served template's data, assert count/order/bytes against the mempool txs; unknown-id error. -- **Green:** session cache read path. +- **Green:** per-session template map retaining the witness-serialized + txs (the named RAM trade), and its read path. - **Refactor:** none expected. - **Verify:** same journey filter. From 006a848c629205e55a794cea003100b64a1c552d Mon Sep 17 00:00:00 2001 From: Gary Krause Date: Tue, 29 Sep 2026 10:32:06 -0400 Subject: [PATCH 04/31] sv2: SetNewPrevHash and a sync gate before the first template 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 --- crates/rbitcoin-sv2/Cargo.toml | 2 +- crates/rbitcoin-sv2/src/session.rs | 65 ++++++++++-- crates/rbitcoin-sv2/src/template.rs | 56 +++++++++-- crates/rbitcoin-sv2/src/template_tests.rs | 115 +++++++++++++++++++--- crates/rbitcoin-sv2/src/test_chain.rs | 6 +- docs/sv2-template-provider.md | 54 ++++++---- 6 files changed, 251 insertions(+), 47 deletions(-) diff --git a/crates/rbitcoin-sv2/Cargo.toml b/crates/rbitcoin-sv2/Cargo.toml index 3cb25d24c..972e8cf83 100644 --- a/crates/rbitcoin-sv2/Cargo.toml +++ b/crates/rbitcoin-sv2/Cargo.toml @@ -11,6 +11,7 @@ description = "Stratum v2 Template Distribution Protocol server (Noise over TCP) rbitcoin-log = { workspace = true } rbitcoin-consensus = { workspace = true } rbitcoin-net = { workspace = true } +rbitcoin-primitives = { workspace = true } rbitcoin-store = { workspace = true } bitcoin = { workspace = true } tokio = { workspace = true } @@ -24,7 +25,6 @@ common_messages_sv2 = "=9.0.0" template_distribution_sv2 = "=7.0.0" [dev-dependencies] -rbitcoin-primitives = { workspace = true } rbitcoin-query = { workspace = true } [lints] diff --git a/crates/rbitcoin-sv2/src/session.rs b/crates/rbitcoin-sv2/src/session.rs index 02e7132cf..3a6f25213 100644 --- a/crates/rbitcoin-sv2/src/session.rs +++ b/crates/rbitcoin-sv2/src/session.rs @@ -16,8 +16,10 @@ use std::io; use std::sync::Arc; use template_distribution_sv2::{ CoinbaseOutputConstraints, MESSAGE_TYPE_COINBASE_OUTPUT_CONSTRAINTS, MESSAGE_TYPE_NEW_TEMPLATE, + MESSAGE_TYPE_SET_NEW_PREV_HASH, }; use tokio::net::TcpStream; +use tokio::sync::broadcast::error::RecvError; const TDP_VERSION: u16 = 2; @@ -41,6 +43,7 @@ pub(crate) async fn serve( let mut conn = NoiseConn::accept(stream, responder).await?; let mut phase = Phase::AwaitingSetup; let mut last_template_id = 0u64; + let mut current_prev = None; loop { let frame = conn.recv().await?; let next = match phase { @@ -49,7 +52,14 @@ pub(crate) async fn serve( if frame.msg_type == MESSAGE_TYPE_COINBASE_OUTPUT_CONSTRAINTS => { last_template_id += 1; - on_constraints(&mut conn, &chain, frame, last_template_id).await? + on_constraints( + &mut conn, + &chain, + frame, + last_template_id, + &mut current_prev, + ) + .await? } Phase::AwaitingConstraints | Phase::Active => { rbitcoin_log::info!("sv2: ignoring message {:#x}", frame.msg_type); @@ -111,6 +121,7 @@ async fn on_constraints( chain: &Arc, mut frame: Frame, template_id: u64, + current_prev: &mut Option<[u8; 32]>, ) -> io::Result { let Ok(c) = binary_sv2::from_bytes::(&mut frame.payload) else { rbitcoin_log::info!("sv2: undecodable CoinbaseOutputConstraints"); @@ -120,16 +131,52 @@ async fn on_constraints( c.coinbase_output_max_additional_size, c.coinbase_output_max_additional_sigops, ); - let chain = Arc::clone(chain); - let t = tokio::task::spawn_blocking(move || { - let _g = BlockingRegion::enter(); - template::build(&chain, size, sigops) - }) - .await - .map_err(io::Error::other)?; + let Some(t) = synced_template(chain, size, sigops).await? else { + return Ok(Next::Close); + }; + // sv2-spec 07 §7.3: a template on a new prev hash is future, then activated. + let new_prev = *current_prev != Some(t.prev_hash); let msg = t - .to_message(template_id, true) + .to_message(template_id, new_prev) .map_err(|e| io::Error::other(format!("sv2 NewTemplate: {e:?}")))?; conn.send(MESSAGE_TYPE_NEW_TEMPLATE, msg).await?; + if new_prev { + conn.send(MESSAGE_TYPE_SET_NEW_PREV_HASH, t.to_prev_hash(template_id)) + .await?; + *current_prev = Some(t.prev_hash); + } Ok(Next::Continue(Phase::Active)) } + +/// No template while in IBD. Leaving IBD always comes with a new tip, so the +/// gate rechecks on tip events. `None`: the tip channel closed (shutdown). +async fn synced_template( + chain: &Arc, + size: u32, + sigops: u16, +) -> io::Result> { + let mut tips = chain.subscribe_tips(); + let mut logged = false; + loop { + let c = Arc::clone(chain); + let t = tokio::task::spawn_blocking(move || { + let _g = BlockingRegion::enter(); + (!c.in_ibd()) + .then(|| template::build(&c, size, sigops)) + .transpose() + }) + .await + .map_err(io::Error::other)??; + if t.is_some() { + return Ok(t); + } + if !logged { + rbitcoin_log::info!("sv2: holding templates until the node leaves IBD"); + logged = true; + } + match tips.recv().await { + Ok(_) | Err(RecvError::Lagged(_)) => {} + Err(RecvError::Closed) => return Ok(None), + } + } +} diff --git a/crates/rbitcoin-sv2/src/template.rs b/crates/rbitcoin-sv2/src/template.rs index fdf311105..bf082b018 100644 --- a/crates/rbitcoin-sv2/src/template.rs +++ b/crates/rbitcoin-sv2/src/template.rs @@ -4,12 +4,15 @@ use binary_sv2::{Seq0255, B0255, B064K, U256}; use bitcoin::consensus::encode::serialize; use bitcoin::hashes::Hash; -use bitcoin::{Amount, ScriptBuf, TxOut}; +use bitcoin::{Amount, ScriptBuf, Target, TxOut}; use rbitcoin_consensus::{ - bip34_height_script, block_subsidy, witness_commitment_script, MAX_BLOCK_WEIGHT, + bip34_height_script, block_subsidy, expected_next_bits, median_time_past, + witness_commitment_script, MAX_BLOCK_WEIGHT, }; use rbitcoin_net::{ChainHub, SelectBudget}; -use template_distribution_sv2::NewTemplate; +use rbitcoin_primitives::Height; +use std::io; +use template_distribution_sv2::{NewTemplate, SetNewPrevHash}; /// sv2-spec 07 §7.1: coinbase weight outside the client's additional outputs, /// and the floor on the whole reserve. @@ -23,6 +26,11 @@ pub(crate) struct Template { /// The witness commitment output, serialized with no count prefix. pub coinbase_outputs: Vec, pub merkle_path: Vec<[u8; 32]>, + pub prev_hash: [u8; 32], + pub header_timestamp: u32, + pub n_bits: u32, + /// `n_bits` expanded, little-endian (no weak-block target). + pub target: [u8; 32], } impl Template { @@ -47,16 +55,45 @@ impl Template { merkle_path: Seq0255::new(self.merkle_path.iter().map(U256::from).collect())?, }) } + + pub fn to_prev_hash(&self, template_id: u64) -> SetNewPrevHash<'_> { + SetNewPrevHash { + template_id, + prev_hash: U256::from(&self.prev_hash), + header_timestamp: self.header_timestamp, + n_bits: self.n_bits, + target: U256::from(&self.target), + } + } } /// Template on the current tip for one client's coinbase constraints. The /// client's sigops replace the default reserve (Core `BlockAssembler`). -/// Takes the mempool lock: blocking region only. +/// Takes the mempool lock and reads the store: blocking region only. pub(crate) fn build( chain: &ChainHub, max_additional_size: u32, max_additional_sigops: u16, -) -> Template { +) -> io::Result