diff --git a/COMPAT.md b/COMPAT.md index 3490e018c..d421547ee 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -72,6 +72,7 @@ Full `/tx/:txid` JSON still has `vin[]`. Electrum has no outspend-vin surface. | Package submit | RPC `submitpackage` / Esplora `POST /txs/package` (no P2P package command) | BIP331 wire | | Pruning / GUI | Unpruned: `seqsigwit.body`. `--prune-seqsigwit`: watermark + `NETWORK_LIMITED`, kept witness is 288 height files under `store/seqsigwit.window/` plus a RAM cap (`--prune-seqsigwit-ram-threshold-bytes`, `0` = files only). Not a rolling stem. Not Core `-prune` of headers/txout | Supported | | Mining template RPC | `getblocktemplate` / `getmininginfo` / `prioritisetransaction` (selector; no stratum) | GBT + stratum / pool stack | +| SV2 Template Distribution | In-process TDP v2 server over Noise NX (`--sv2-tp-listen`, default off): `NewTemplate` / `SetNewPrevHash` per tip, `RequestTransactionData`, `SubmitSolution` → block accept. No templates during IBD; last 3 per session. No plaintext, no pool / JD roles | No TDP in Core; sv2-apps `bitcoin-core-sv2` bridges Core's IPC mining interface to TDP | | Wallets | Electrum clients (requires `--shindex`) | Descriptor + legacy | | Scripthash index | Optional (`--shindex`, default **off**); bulk at tip when on | External ElectrumX / Fulcrum; Core `-txindex` is different (txid→block) | | JSON-RPC | Documented **subset** ([`docs/rpc.md`](./docs/rpc.md)); Bearer token or opt-in Core cookie (`--rpc-cookie-file`), no `--rpcuser` / `--rpcpassword`; `rbitcoin-cli`. `--rpc-work-queue` defaults to **16** (HTTP occupancy, 503 when full; **0** is that default); a JSON-RPC array is one POST | Full Core RPC; `-rpcworkqueue` is in-flight HTTP jobs (503) | diff --git a/Cargo.lock b/Cargo.lock index 0d2179296..5b15a7084 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" @@ -780,6 +1018,7 @@ dependencies = [ "rbitcoin-query", "rbitcoin-rpc", "rbitcoin-store", + "rbitcoin-sv2", "serde_json", "tokio", "tower-http", @@ -834,10 +1073,30 @@ dependencies = [ "rbitcoin-primitives", ] +[[package]] +name = "rbitcoin-sv2" +version = "0.7.99" +dependencies = [ + "binary_sv2", + "bitcoin", + "codec_sv2", + "common_messages_sv2", + "noise_sv2", + "rbitcoin-consensus", + "rbitcoin-log", + "rbitcoin-net", + "rbitcoin-primitives", + "rbitcoin-query", + "rbitcoin-store", + "template_distribution_sv2", + "tokio", +] + [[package]] name = "rbitcoin-test" version = "0.7.99" dependencies = [ + "binary_sv2", "bitcoin", "rbitcoin-cli", "rbitcoin-consensus", @@ -850,7 +1109,9 @@ dependencies = [ "rbitcoin-query", "rbitcoin-rpc", "rbitcoin-store", + "rbitcoin-sv2", "serde_json", + "template_distribution_sv2", "tokio", ] @@ -875,6 +1136,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 +1155,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 +1297,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" @@ -1044,6 +1331,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" @@ -1169,6 +1465,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 +1522,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..54f71846f 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" } @@ -62,6 +64,14 @@ arc-swap = "1" # Process-wide heap for product binaries (musl static + glibc). Replaces the # platform default (musl/glibc malloc) via #[global_allocator] in each bin. mimalloc = "0.1" +# 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" +template_distribution_sv2 = "=7.0.0" # Fail the build on rustc warnings (unused imports, dead code, …). [workspace.lints.rust] diff --git a/OPERATOR.md b/OPERATOR.md index 504bd2aaa..42921ec9e 100644 --- a/OPERATOR.md +++ b/OPERATOR.md @@ -7,7 +7,7 @@ Start with the task you need to do: | Understand node status, build a binary, configure a service | [Setup and installation](docs/operator/setup.md) | | Try regtest, use the CLI, monitor the node (logs, health probes, metrics), configure relay and P2P | [Node operations](docs/operator/operations.md) | | Tune store IO and memory, upgrade the schema, manage optional indexes | [Storage and indexes](docs/operator/storage.md) | -| Configure Electrum, Esplora, RPC, or client benchmarking | [Client interfaces](docs/operator/interfaces.md) | +| Configure Electrum, Esplora, RPC, SV2 templates, or client benchmarking | [Client interfaces](docs/operator/interfaces.md) | | Run signet/mainnet, or tune a constrained host or uplink | [Labs and constrained hosts](docs/operator/field-notes.md) | Product compatibility: [`COMPAT.md`](COMPAT.md). Current on-disk format and diff --git a/changelog.d/sv2-template-provider.md b/changelog.d/sv2-template-provider.md new file mode 100644 index 000000000..2a960835e --- /dev/null +++ b/changelog.d/sv2-template-provider.md @@ -0,0 +1,10 @@ +Added + +- **Stratum v2 Template Provider.** `--sv2-tp-listen ADDR` serves the + SV2 Template Distribution Protocol over Noise. A JDC or pool gets a + template on every tip, can fetch its transactions, and can submit a + solved block, which the node accepts like any other block. + `--sv2-tp-authority-sec-file` (or `--sv2-tp-authority-sec`) sets the + signing key. `--sv2-tp-cert-validity` and `--sv2-tp-stale-grace` tune + the certificates and old-tip templates. NixOS: + `services.rbitcoin.sv2.tp.*`. Default off. diff --git a/crates/rbitcoin-consensus/src/block/mod.rs b/crates/rbitcoin-consensus/src/block/mod.rs index 8978ab8ba..b0367789c 100644 --- a/crates/rbitcoin-consensus/src/block/mod.rs +++ b/crates/rbitcoin-consensus/src/block/mod.rs @@ -301,7 +301,7 @@ fn coinbase_has_witness_commitment(block: &Block) -> bool { } /// Last BIP141 `OP_RETURN` witness commitment (exact 38-byte `6a24aa21a9ed` prefix). -pub(crate) fn witness_commitment_vout_index(coinbase: &Transaction) -> Option { +pub fn witness_commitment_vout_index(coinbase: &Transaction) -> Option { const MAGIC: [u8; 6] = [0x6a, 0x24, 0xaa, 0x21, 0xa9, 0xed]; coinbase .output diff --git a/crates/rbitcoin-consensus/src/lib.rs b/crates/rbitcoin-consensus/src/lib.rs index 3543f3762..3292d78e8 100644 --- a/crates/rbitcoin-consensus/src/lib.rs +++ b/crates/rbitcoin-consensus/src/lib.rs @@ -78,9 +78,9 @@ pub fn verify_tx_scripts_with_flags( pub use block::{ bip34_height_script, bip68_active_for_tx, block_has_witness, block_mutated_without_coinbase, block_subsidy, check_block_wire, is_final_tx, sequence_locks_satisfied, tx_sigop_cost, - validate_block_structure, witness_commitment_script, ScriptVerifyFlags, ValidationContext, - BIP16_EXCEPTION_MAINNET, MAX_BLOCK_TX_COUNT, MAX_BLOCK_WEIGHT, MIN_TX_WEIGHT, - TAPROOT_EXCEPTION_MAINNET, + validate_block_structure, witness_commitment_script, witness_commitment_vout_index, + ScriptVerifyFlags, ValidationContext, BIP16_EXCEPTION_MAINNET, MAX_BLOCK_TX_COUNT, + MAX_BLOCK_WEIGHT, MIN_TX_WEIGHT, TAPROOT_EXCEPTION_MAINNET, }; pub(crate) use block::{validate_block_structure_hashed, TxPrecompute}; pub use clock::{with_now, NodeClock}; 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-node/Cargo.toml b/crates/rbitcoin-node/Cargo.toml index 2b714494c..bedc9dab5 100644 --- a/crates/rbitcoin-node/Cargo.toml +++ b/crates/rbitcoin-node/Cargo.toml @@ -22,6 +22,7 @@ rbitcoin-net = { workspace = true } rbitcoin-rpc = { workspace = true } rbitcoin-electrum = { workspace = true } rbitcoin-esplora = { workspace = true } +rbitcoin-sv2 = { workspace = true } bitcoin = { workspace = true } bitcoin_hashes = { workspace = true } serde_json = { workspace = true } diff --git a/crates/rbitcoin-node/src/cli.rs b/crates/rbitcoin-node/src/cli.rs index 6adb3ea94..35963392c 100644 --- a/crates/rbitcoin-node/src/cli.rs +++ b/crates/rbitcoin-node/src/cli.rs @@ -310,6 +310,7 @@ fn operator_usage() -> String { [--tor-control [HOST:PORT]] [--tor-control-cookie PATH] [--tor-control-password PASS] \\\n\ [--i2p-sam [HOST:PORT]] [--i2p-accept-incoming] \\\n\ [--electrum-listen ADDR] [--esplora-listen ADDR] [--esplora-onion[=0|1]] [--health-listen [ADDR]] [--metrics] \\\n\ + [--sv2-tp-listen ADDR] [--sv2-tp-authority-sec KEY] [--sv2-tp-authority-sec-file PATH] [--sv2-tp-cert-validity SECS] [--sv2-tp-stale-grace SECS] \\\n\ [--sh-index] [--block-filter-index] [--prune-seqsigwit] [--prune-seqsigwit-ram-threshold-bytes N] [--sp-tweaks] [--sp-tweaks-dust SATS] [--max-sh-creates N] [--electrum-max-subs N] [--esplora-block-template] \\\n\ [--rpc] [--rpc-listen [ADDR]] [--rest] [--rpc-socket PATH] [--rpc-token-file PATH] [--rpc-cookie-file PATH] [--rpc-work-queue N] \\\n\ [--milestone HEIGHT] \\\n\ @@ -366,6 +367,11 @@ Health: --health-listen [ADDR] serves GET /healthz, GET /readyz, and GET /progre the running index build, rebuild, or backfill stage, at any log level) from the first\n\ second of startup (default 127.0.0.1:9332). Unauthenticated; keep it on loopback or a\n\ probe-only network. --metrics adds Prometheus GET /metrics there (needs --health-listen).\n\ +SV2: --sv2-tp-listen ADDR serves the Stratum v2 Template Distribution Protocol (Noise over TCP; default off).\n\ + Requires --sv2-tp-authority-sec KEY (secp256k1 secret, 64 hex or key-utils base58check; argv exposes it) or --sv2-tp-authority-sec-file PATH\n\ + (the same hex in a file). The x-only pubkey is logged.\n\ + --sv2-tp-cert-validity SECS per-connection certificate lifetime, at most 4294967295 (default 3600).\n\ + --sv2-tp-stale-grace SECS keeps a replaced tip's templates answering for SECS, at most 86400 (default 10).\n\ RPC: --rpc unix socket {{datadir}}/rpc.sock; --rpc-listen [ADDR] adds TCP (default 127.0.0.1 and Core-matching port). Token {{datadir}}/rpc.token (Bearer); --rpc-cookie-file opts TCP into Core cookie HTTP Basic. No --rpcuser. --rest turns on unauthenticated /rest/ on those listeners (off unless set).\n\ Cold files: --datadir-cold PATH puts Class A seqsigwit.body/idx under PATH/store (HDD).\n\ Default (flag omitted): hot and cold files both live under --datadir.\n\ @@ -633,6 +639,11 @@ mod tests { "--sp-tweaks-dust", "--esplora-block-template", "--esplora-onion", + "--sv2-tp-listen", + "--sv2-tp-authority-sec", + "--sv2-tp-authority-sec-file", + "--sv2-tp-cert-validity", + "--sv2-tp-stale-grace", "--rpc", "--rpc-listen", "--rest", diff --git a/crates/rbitcoin-node/src/config.rs b/crates/rbitcoin-node/src/config.rs index faec21d9f..355ebee9d 100644 --- a/crates/rbitcoin-node/src/config.rs +++ b/crates/rbitcoin-node/src/config.rs @@ -236,6 +236,16 @@ impl std::fmt::Debug for TorControlOpts { } } +/// SV2 authority secret key; `Debug` never prints it. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct Sv2AuthoritySecret(pub [u8; 32]); + +impl std::fmt::Debug for Sv2AuthoritySecret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("****") + } +} + /// JSON-RPC listen and auth. #[derive(Clone, Debug, PartialEq, Eq)] pub struct RpcOpts { @@ -322,6 +332,14 @@ pub struct NodeConfig { pub metrics: bool, /// ADD_ONION for `--esplora-listen` when `--tor-control` is set. Default on. pub esplora_onion: bool, + /// SV2 Template Provider bind (`--sv2-tp-listen`). Default off. + pub sv2_tp_listen: Option, + /// Authority secret key the TP signs its Noise certificates with. + pub sv2_tp_authority_sec: Option, + /// Validity of each per-connection Noise certificate. Default 3600 s. + pub sv2_tp_cert_validity_secs: u64, + /// How long a template on a replaced tip still answers. Default 10 s. + pub sv2_tp_stale_grace_secs: u64, /// Skip script/prevout checks for blocks at or below this height (0 = off). pub milestone_height: u32, /// Set when conf or CLI applied `milestone` (including 0). @@ -400,6 +418,10 @@ impl Default for NodeConfig { esplora_block_template: false, metrics: false, esplora_onion: true, + sv2_tp_listen: None, + sv2_tp_authority_sec: None, + sv2_tp_cert_validity_secs: 3600, + sv2_tp_stale_grace_secs: 10, milestone_height: 0, milestone_explicit: false, inhibit_suspend: false, @@ -625,6 +647,12 @@ impl NodeConfig { "--rest needs --rpc or --rpc-listen".into(), )); } + if self.sv2_tp_listen.is_some() && self.sv2_tp_authority_sec.is_none() { + return Err(NodeError::Config( + "--sv2-tp-listen requires --sv2-tp-authority-sec or --sv2-tp-authority-sec-file" + .into(), + )); + } self.validate_only_net()?; self.validate_hidden_inbound()?; self.validate_rpc_cookie() @@ -1104,6 +1132,43 @@ impl NodeConfig { self.esplora_onion = parse_conf_bool(val) .map_err(|e| NodeError::Config(format!("conf esplora_onion: {e}")))?; } + "sv2_tp_listen" => { + self.sv2_tp_listen = Some( + val.parse() + .map_err(|e| NodeError::Config(format!("conf sv2_tp_listen: {e}")))?, + ); + } + "sv2_tp_authority_sec" => { + self.sv2_tp_authority_sec = Some(parse_authority_sec(&key_l, val)?); + } + // Keeps the secret out of argv and the unit file. + "sv2_tp_authority_sec_file" => { + let hex = std::fs::read_to_string(val).map_err(|e| { + // The path may be the key pasted into the wrong knob: never echo it. + NodeError::Config(format!("conf sv2_tp_authority_sec_file: {e}")) + })?; + self.sv2_tp_authority_sec = Some(parse_authority_sec(&key_l, hex.trim())?); + } + "sv2_tp_stale_grace" => { + let max = rbitcoin_sv2::MAX_STALE_GRACE.as_secs(); + self.sv2_tp_stale_grace_secs = + val.parse().ok().filter(|&s| s <= max).ok_or_else(|| { + NodeError::Config(format!("conf sv2_tp_stale_grace: want seconds <= {max}")) + })?; + } + "sv2_tp_cert_validity" => { + self.sv2_tp_cert_validity_secs = val + .parse::() + .ok() + .filter(|&s| s > 0) + .ok_or_else(|| { + NodeError::Config(format!( + "conf sv2_tp_cert_validity: want seconds in 1..={}", + u32::MAX + )) + })? + .into(); + } "rpc" => { self.rpc.socket = parse_conf_bool(val) .map_err(|e| NodeError::Config(format!("conf rpc: {e}")))?; @@ -1447,6 +1512,21 @@ fn is_conf_true(val: &str) -> bool { ) } +/// 64 hex, or SRI `key-utils` `Secp256k1SecretKey` base58check (the raw 32 bytes). +/// The error never echoes `val`: it is a secret. +fn parse_authority_sec(key: &str, val: &str) -> Result { + <[u8; 32]>::from_hex(val) + .ok() + .or_else(|| bitcoin::base58::decode_check(val).ok()?.try_into().ok()) + .filter(|k| bitcoin::secp256k1::SecretKey::from_slice(k).is_ok()) + .map(Sv2AuthoritySecret) + .ok_or_else(|| { + NodeError::Config(format!( + "conf {key}: want a secp256k1 secret key (64 hex or key-utils base58check)" + )) + }) +} + /// Parse `1`/`true`/`yes`/`on` → true; `0`/`false`/`no`/`off` → false. fn parse_conf_bool(val: &str) -> Result { let v = val.to_ascii_lowercase(); @@ -1618,6 +1698,137 @@ mod tests { assert!(err.contains("rpc_socket requires a path"), "{err}"); } + #[test] + fn sv2_tp_apply_kv_and_listen_requires_authority() { + let mut c = NodeConfig::default().with_datadir(tmp()); + assert_eq!(c.sv2_tp_listen, None); + assert_eq!(c.sv2_tp_cert_validity_secs, 3600); + assert_eq!(c.sv2_tp_stale_grace_secs, 10); + for (k, v) in [ + ("sv2_tp_listen", "127.0.0.1:8442"), + ("sv2_tp_cert_validity", "600"), + ("sv2_tp_stale_grace", "0"), + ] { + assert_eq!(c.apply_kv(k, v).unwrap(), ConfApply::Applied); + } + assert_eq!(c.sv2_tp_listen, Some("127.0.0.1:8442".parse().unwrap())); + assert_eq!(c.sv2_tp_cert_validity_secs, 600); + assert_eq!(c.sv2_tp_stale_grace_secs, 0); + let no_auth = c.validate().unwrap_err(); + assert!( + format!("{no_auth}").contains("sv2-tp-authority-sec"), + "{no_auth}" + ); + assert_eq!( + c.apply_kv("sv2_tp_authority_sec", &"07".repeat(32)) + .unwrap(), + ConfApply::Applied + ); + assert_eq!(c.sv2_tp_authority_sec, Some(Sv2AuthoritySecret([7; 32]))); + assert!(c.validate().is_ok()); + + let zero = "00".repeat(32); + let not_hex = "zz".repeat(32); + for (k, v) in [ + ("sv2_tp_listen", "nope"), + ("sv2_tp_authority_sec", "07"), + ("sv2_tp_authority_sec", zero.as_str()), + ("sv2_tp_authority_sec", not_hex.as_str()), + ("sv2_tp_cert_validity", "0"), + ("sv2_tp_stale_grace", "-1"), + ("sv2_tp_stale_grace", "86401"), + ("sv2_tp_cert_validity", "4294967296"), + ] { + let e = format!("{}", c.apply_kv(k, v).unwrap_err()); + assert!(e.contains(k), "garbage must name the knob: {e}"); + } + } + + #[test] + fn sv2_tp_authority_sec_file_reads_the_key_off_argv() { + let dir = tmp(); + std::fs::create_dir_all(&dir).unwrap(); + let mut c = NodeConfig::default().with_datadir(dir.clone()); + let key = dir.join("sv2-authority.key"); + std::fs::write(&key, format!("{}\n", "07".repeat(32))).unwrap(); + let path = key.to_str().unwrap(); + assert_eq!( + c.apply_kv("sv2_tp_authority_sec_file", path).unwrap(), + ConfApply::Applied + ); + assert_eq!(c.sv2_tp_authority_sec, Some(Sv2AuthoritySecret([7; 32]))); + + let bad = dir.join("sv2-bad.key"); + std::fs::write(&bad, "00".repeat(32)).unwrap(); + let missing = dir.join("sv2-missing.key"); + for p in [&bad, &missing] { + let e = format!( + "{}", + c.apply_kv("sv2_tp_authority_sec_file", p.to_str().unwrap()) + .unwrap_err() + ); + assert!(e.contains("sv2_tp_authority_sec_file"), "{e}"); + assert!(!e.contains(&"00".repeat(32)), "never echo the key: {e}"); + } + } + + /// An operator who passes the key itself to the file knob, or logs the + /// config, must not see the key printed. + #[test] + fn sv2_tp_authority_secret_never_prints() { + let hex = "07".repeat(32); + let mut c = NodeConfig::default(); + c.apply_kv("sv2_tp_authority_sec", &hex).unwrap(); + let dbg = format!("{c:?}"); + assert!(dbg.contains("sv2_tp_authority_sec"), "{dbg}"); + assert!(!dbg.contains("[7, 7"), "never print the key: {dbg}"); + assert!(!dbg.contains(&hex), "never print the key: {dbg}"); + + let e = format!( + "{}", + c.apply_kv("sv2_tp_authority_sec_file", &hex).unwrap_err() + ); + assert!(e.contains("sv2_tp_authority_sec_file"), "{e}"); + assert!(!e.contains(&hex), "never echo the key: {e}"); + } + + /// key-utils 1.2.0 vector: SRI configs carry the authority secret as + /// `Secp256k1SecretKey` base58check; its pubkey is the one the TP prints. + #[test] + fn sv2_tp_authority_sec_takes_key_utils_base58check() { + let secret = "zmBEmPhqo3A92FkiLVvyCz6htc3e53ph3ZbD4ASqGaLjwnFLi"; + let dir = tmp(); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("sv2-authority.key"); + std::fs::write(&file, format!("{secret}\n")).unwrap(); + for (k, v) in [ + ("sv2_tp_authority_sec", secret), + ("sv2_tp_authority_sec_file", file.to_str().unwrap()), + ] { + let mut c = NodeConfig::default().with_datadir(dir.clone()); + assert_eq!(c.apply_kv(k, v).unwrap(), ConfApply::Applied); + let kp = bitcoin::secp256k1::Keypair::from_seckey_slice( + &bitcoin::secp256k1::Secp256k1::new(), + &c.sv2_tp_authority_sec.unwrap().0, + ) + .unwrap(); + let mut pk = vec![1, 0]; + pk.extend(kp.x_only_public_key().0.serialize()); + assert_eq!( + bitcoin::base58::encode_check(&pk), + "9bDuixKmZqAJnrmP746n8zU1wyAQRrus7th9dxnkPg6RzQvCnan" + ); + } + let mut c = NodeConfig::default(); + let bad_check = format!("{}1", &secret[..secret.len() - 1]); + let e = format!( + "{}", + c.apply_kv("sv2_tp_authority_sec", &bad_check).unwrap_err() + ); + assert!(e.contains("sv2_tp_authority_sec"), "{e}"); + assert!(!e.contains(&bad_check), "never echo the key: {e}"); + } + #[test] fn max_sh_creates_and_esplora_block_template_apply_kv() { let mut c = NodeConfig::default(); diff --git a/crates/rbitcoin-node/src/lib.rs b/crates/rbitcoin-node/src/lib.rs index 0b47b704c..d04daece2 100644 --- a/crates/rbitcoin-node/src/lib.rs +++ b/crates/rbitcoin-node/src/lib.rs @@ -12,7 +12,8 @@ mod tor_control; pub use cli::cli_main; pub use config::{ - DatadirOpts, ListenOpts, MempoolOpts, NodeConfig, P2pListen, RpcOpts, TorControlOpts, + DatadirOpts, ListenOpts, MempoolOpts, NodeConfig, P2pListen, RpcOpts, Sv2AuthoritySecret, + TorControlOpts, }; pub use error::NodeError; pub use run::{run_node, run_p2p, NodeHandle}; diff --git a/crates/rbitcoin-node/src/run.rs b/crates/rbitcoin-node/src/run.rs index 99a158938..600d56454 100644 --- a/crates/rbitcoin-node/src/run.rs +++ b/crates/rbitcoin-node/src/run.rs @@ -17,6 +17,7 @@ use rbitcoin_rpc::{ gbt_template, run_rpc, RpcActive, RpcConfig, RpcContext, RpcHandle, RpcRegtest, }; use rbitcoin_store::StoreError; +use rbitcoin_sv2::{run_sv2_tp, Sv2TpConfig, Sv2TpHandle}; use std::net::SocketAddr; use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -842,6 +843,7 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { .set_wallet_onion(format!("{}.onion", hs.service_id), h.local_addr.port()); } } + let sv2_tp = start_sv2_tp(&config, &node.hub).await; let mut i2p_wallet = Vec::new(); if config.listen.i2p_accept_incoming { if let Some(addr) = config.listen.i2p_sam { @@ -1350,6 +1352,9 @@ pub async fn run_p2p(config: NodeConfig) -> Result<(), NodeError> { for e in esplora_handles { e.shutdown().await; } + if let Some(h) = sv2_tp { + h.shutdown().await; + } if let Some(h) = rpc_handle { if h.stop.load(Ordering::SeqCst) { info!("rpc: stop requested via JSON-RPC"); @@ -1851,6 +1856,34 @@ async fn start_esplora_if_ready( } } +async fn start_sv2_tp(config: &NodeConfig, hub: &Arc) -> Option { + let listen = config.sv2_tp_listen?; + let authority_secret = config.sv2_tp_authority_sec?.0; + let cfg = Sv2TpConfig { + listen, + chain: Arc::clone(hub), + authority_secret, + cert_validity: Duration::from_secs(config.sv2_tp_cert_validity_secs), + stale_grace: Duration::from_secs(config.sv2_tp_stale_grace_secs), + setup_timeout: rbitcoin_sv2::SETUP_TIMEOUT, + write_timeout: rbitcoin_sv2::WRITE_TIMEOUT, + }; + match run_sv2_tp(cfg).await { + Ok(h) => { + info!( + "sv2 TP on {} (authority pubkey {})", + h.local_addr, + h.authority_key() + ); + Some(h) + } + Err(e) => { + warn!("sv2 TP start warning: {e}"); + None + } + } +} + /// Result of post-IBD tip entry: follow/mempool gates vs Electrum SH gate. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct TipModeGates { 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..a89ea8914 100644 --- a/crates/rbitcoin-store/src/integrity.rs +++ b/crates/rbitcoin-store/src/integrity.rs @@ -134,20 +134,52 @@ 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 +} + +/// Root reached by folding `leaf` at `index` with its [`merkle_branch`]. The +/// leaf is the left child when the index bit at that depth is 0. +pub fn merkle_root_from_branch(leaf: [u8; 32], branch: &[[u8; 32]], index: usize) -> [u8; 32] { + branch + .iter() + .enumerate() + .fold(leaf, |acc, (depth, sibling)| { + if (index >> depth) & 1 == 0 { + hash256_concat(&acc, sibling) + } else { + hash256_concat(sibling, &acc) + } + }) +} + +// 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 +600,42 @@ 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 branch = merkle_branch(&leaves, index); + let mut h = *leaf; + for (depth, sib) in branch.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}"); + assert_eq!( + merkle_root_from_branch(*leaf, &branch, index), + 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..97343de71 100644 --- a/crates/rbitcoin-store/src/lib.rs +++ b/crates/rbitcoin-store/src/lib.rs @@ -81,7 +81,10 @@ 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_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/crates/rbitcoin-sv2/Cargo.toml b/crates/rbitcoin-sv2/Cargo.toml new file mode 100644 index 000000000..fb0c3c53e --- /dev/null +++ b/crates/rbitcoin-sv2/Cargo.toml @@ -0,0 +1,28 @@ +[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 } +rbitcoin-consensus = { workspace = true } +rbitcoin-net = { workspace = true } +rbitcoin-primitives = { workspace = true } +rbitcoin-store = { workspace = true } +bitcoin = { workspace = true } +tokio = { workspace = true } +noise_sv2 = { workspace = true } +codec_sv2 = { workspace = true } +binary_sv2 = { workspace = true } +common_messages_sv2 = { workspace = true } +template_distribution_sv2 = { workspace = true } + +[dev-dependencies] +rbitcoin-query = { workspace = true } + +[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..0603ecc2d --- /dev/null +++ b/crates/rbitcoin-sv2/src/lib.rs @@ -0,0 +1,188 @@ +//! 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; +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}; +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(crate) const MAX_SESSIONS: usize = 8; + +/// Default [`Sv2TpConfig::setup_timeout`]. A session holds a slot from TCP +/// accept, so without it `MAX_SESSIONS` silent sockets lock clients out. +pub const SETUP_TIMEOUT: Duration = Duration::from_secs(10); + +/// Default [`Sv2TpConfig::write_timeout`]. +pub const WRITE_TIMEOUT: Duration = Duration::from_secs(30); + +/// Upper bound on [`Sv2TpConfig::stale_grace`]; the grace deadline is +/// `Instant + stale_grace`, which panics on overflow. +pub const MAX_STALE_GRACE: Duration = Duration::from_secs(24 * 60 * 60); + +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. + pub cert_validity: Duration, + /// How long a template on a replaced prev hash still answers requests. + pub stale_grace: Duration, + /// Deadline from TCP accept through the Noise handshake, + /// `SetupConnection`, and the first `CoinbaseOutputConstraints` (without + /// which the session never writes). No read deadline after that: TDP has + /// no keepalive and a client may stay silent while the TP pushes. + pub setup_timeout: Duration, + /// A socket write that makes no progress this long closes the session, + /// so a client that stops reading cannot hold a slot. + pub write_timeout: Duration, +} + +pub struct Sv2TpHandle { + pub local_addr: SocketAddr, + /// X-only authority public key the clients verify the certificate against. + pub(crate) authority_pubkey: [u8; 32], + task: JoinHandle<()>, + sessions: Arc>>>, +} + +impl Sv2TpHandle { + /// The authority public key as SRI `key-utils` prints it (the form JDC + /// and pool configs take): base58check of version `1u16` LE, then the + /// x-only key. + pub fn authority_key(&self) -> String { + let mut v = [0u8; 34]; + v[..2].copy_from_slice(&1u16.to_le_bytes()); + v[2..].copy_from_slice(&self.authority_pubkey); + bitcoin::base58::encode_check(&v) + } + + 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 { + // noise_sv2 casts `cert_validity.as_secs()` to u32; a larger value wraps. + if u32::try_from(config.cert_validity.as_secs()).is_err() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "sv2 cert_validity: over u32::MAX seconds", + )); + } + if config.stale_grace > MAX_STALE_GRACE { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("sv2 stale_grace: over {}s", MAX_STALE_GRACE.as_secs()), + )); + } + 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 stale_grace = config.stale_grace; + let setup_timeout = config.setup_timeout; + let write_timeout = config.write_timeout; + 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)); + 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 chain = Arc::clone(&chain); + let h = tokio::spawn(async move { + let _slot = slot; + match session::serve( + stream, + responder, + chain, + stale_grace, + setup_timeout, + write_timeout, + ) + .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; +#[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 new file mode 100644 index 000000000..a74393041 --- /dev/null +++ b/crates/rbitcoin-sv2/src/listener_tests.rs @@ -0,0 +1,342 @@ +use crate::test_chain::padded_chain; +use crate::testutil::TpClient; +use crate::{run_sv2_tp, Sv2TpConfig, MAX_SESSIONS, MAX_STALE_GRACE, SETUP_TIMEOUT, WRITE_TIMEOUT}; +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}; +use template_distribution_sv2::MESSAGE_TYPE_REQUEST_TRANSACTION_DATA_ERROR; + +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 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), + stale_grace: Duration::from_secs(10), + setup_timeout: SETUP_TIMEOUT, + write_timeout: WRITE_TIMEOUT, + }) + .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; +} + +/// key-utils 1.2.0 vector: SRI clients configure the TP authority key in +/// this form, so the handle must print it, and a client must connect with it. +#[tokio::test] +async fn authority_key_prints_in_key_utils_base58check() { + let secret = bitcoin::base58::decode_check("zmBEmPhqo3A92FkiLVvyCz6htc3e53ph3ZbD4ASqGaLjwnFLi") + .expect("vector secret"); + let tc = padded_chain("sv2-authority-key", 0); + let tp = run_sv2_tp(Sv2TpConfig { + listen: "127.0.0.1:0".parse().unwrap(), + chain: std::sync::Arc::clone(&tc.chain), + authority_secret: secret.try_into().expect("32-byte secret"), + cert_validity: Duration::from_secs(3600), + stale_grace: Duration::from_secs(10), + setup_timeout: SETUP_TIMEOUT, + write_timeout: WRITE_TIMEOUT, + }) + .await + .expect("listen"); + let key = tp.authority_key(); + assert_eq!(key, "9bDuixKmZqAJnrmP746n8zU1wyAQRrus7th9dxnkPg6RzQvCnan"); + + let decoded = bitcoin::base58::decode_check(&key).unwrap(); + let pk: [u8; 32] = decoded[2..].try_into().unwrap(); + let mut c = TpClient::connect(tp.local_addr, pk) + .await + .expect("handshake against the printed key"); + 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 _: SetupConnectionSuccess = binary_sv2::from_bytes(&mut f.payload).expect("decode"); + tp.shutdown().await; +} + +/// Silent sockets take every slot at accept; the setup deadline must close +/// them so a real client gets in. +#[tokio::test] +async fn silent_sockets_are_dropped_at_the_setup_deadline() { + use tokio::io::AsyncReadExt; + + let tc = padded_chain("sv2-setup-deadline", 0); + let setup_timeout = Duration::from_millis(300); + let tp = run_sv2_tp(Sv2TpConfig { + listen: "127.0.0.1:0".parse().unwrap(), + chain: std::sync::Arc::clone(&tc.chain), + authority_secret: [7; 32], + cert_validity: Duration::from_secs(3600), + stale_grace: Duration::from_secs(10), + setup_timeout, + write_timeout: WRITE_TIMEOUT, + }) + .await + .expect("listen"); + let (addr, pk) = (tp.local_addr, tp.authority_pubkey); + + let mut silent = Vec::new(); + for _ in 0..MAX_SESSIONS { + silent.push(tokio::net::TcpStream::connect(addr).await.expect("tcp")); + } + for s in &mut silent { + let mut b = [0u8; 1]; + let closed = tokio::time::timeout(setup_timeout * 10, s.read(&mut b)).await; + assert!( + matches!(closed, Ok(Ok(0) | Err(_))), + "silent socket must be closed after the setup deadline" + ); + } + + let mut c = connect_when_free(addr, pk).await; + c.setup_connection(TDP, 2, 2, 0).await.unwrap(); + let f = c.recv().await.expect("setup reply"); + assert_eq!(f.msg_type, MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS); + tp.shutdown().await; +} + +/// A TDP session without `CoinbaseOutputConstraints` never gets a template +/// and never writes, so the setup deadline also covers the first constraints. +/// Other frames before them do not reset it. +#[tokio::test] +async fn session_without_constraints_is_dropped_at_the_setup_deadline() { + let tc = padded_chain("sv2-constraints-deadline", 0); + let setup_timeout = Duration::from_millis(300); + let tp = run_sv2_tp(Sv2TpConfig { + listen: "127.0.0.1:0".parse().unwrap(), + chain: std::sync::Arc::clone(&tc.chain), + authority_secret: [7; 32], + cert_validity: Duration::from_secs(3600), + stale_grace: Duration::from_secs(10), + setup_timeout, + write_timeout: WRITE_TIMEOUT, + }) + .await + .expect("listen"); + let (addr, pk) = (tp.local_addr, tp.authority_pubkey); + + let mut idle = TpClient::connect(addr, pk).await.expect("handshake"); + idle.setup_connection(TDP, 2, 2, 0).await.unwrap(); + idle.recv().await.expect("setup reply"); + idle.request_transaction_data(1).await.unwrap(); + let f = idle + .recv() + .await + .expect("request answered before constraints"); + assert_eq!(f.msg_type, MESSAGE_TYPE_REQUEST_TRANSACTION_DATA_ERROR); + + let mut ok = TpClient::connect(addr, pk).await.expect("handshake"); + ok.setup_connection(TDP, 2, 2, 0).await.unwrap(); + ok.recv().await.expect("setup reply"); + ok.coinbase_output_constraints(1, 1).await.unwrap(); + + let closed = tokio::time::timeout(setup_timeout * 10, async { + while idle.recv().await.is_ok() {} + }) + .await; + assert!(closed.is_ok(), "session without constraints must be closed"); + + tokio::time::sleep(setup_timeout * 2).await; + ok.request_transaction_data(u64::MAX).await.unwrap(); + loop { + let f = ok + .recv() + .await + .expect("session with constraints stays open"); + if f.msg_type == MESSAGE_TYPE_REQUEST_TRANSACTION_DATA_ERROR { + break; + } + } + tp.shutdown().await; +} + +/// A client that floods requests and never reads jams the TP's writes; the +/// write deadline must close the session instead of stalling it forever. +#[tokio::test] +async fn client_that_stops_reading_is_dropped_at_the_write_deadline() { + let tc = padded_chain("sv2-write-deadline", 0); + let write_timeout = Duration::from_millis(200); + let tp = run_sv2_tp(Sv2TpConfig { + listen: "127.0.0.1:0".parse().unwrap(), + chain: std::sync::Arc::clone(&tc.chain), + authority_secret: [7; 32], + cert_validity: Duration::from_secs(3600), + stale_grace: Duration::from_secs(10), + setup_timeout: SETUP_TIMEOUT, + write_timeout, + }) + .await + .expect("listen"); + let mut c = TpClient::connect(tp.local_addr, tp.authority_pubkey) + .await + .expect("handshake"); + c.setup_connection(TDP, 2, 2, 0).await.unwrap(); + c.recv().await.expect("setup reply"); + // Without constraints the setup deadline closes the session at + // SETUP_TIMEOUT: the flood must lose only to the write deadline. The + // stale tip holds the template, so constraints add no traffic. + c.coinbase_output_constraints(0, 0).await.unwrap(); + + // Each unknown id answers RequestTransactionData.Error, which the + // client never reads: the TP blocks on write, then stops reading. + let mut jammed = false; + for id in 1..=1_000_000u64 { + let sent = tokio::time::timeout(write_timeout, c.request_transaction_data(id)).await; + // A stall past the write deadline, or a fast write error once the + // closed session turns sends into EPIPE: the pipe is dead either way. + if !matches!(sent, Ok(Ok(()))) { + jammed = true; + break; + } + } + assert!(jammed, "socket buffers never filled"); + tokio::time::sleep(write_timeout * 3).await; + + let drained = tokio::time::timeout(Duration::from_secs(10), async { + while c.recv().await.is_ok() {} + }) + .await; + assert!( + drained.is_ok(), + "session must close once its write stalls past the deadline" + ); + tp.shutdown().await; +} + +/// The largest legitimate client frame (a `SubmitSolution` with a full +/// `B064K` coinbase) keeps the session; a larger frame closes it. +#[tokio::test] +async fn oversized_client_frame_closes_the_session() { + let tc = padded_chain("sv2-frame-cap", 0); + let tp = run_sv2_tp(Sv2TpConfig { + listen: "127.0.0.1:0".parse().unwrap(), + chain: std::sync::Arc::clone(&tc.chain), + authority_secret: [7; 32], + cert_validity: Duration::from_secs(3600), + stale_grace: Duration::from_secs(10), + setup_timeout: SETUP_TIMEOUT, + write_timeout: WRITE_TIMEOUT, + }) + .await + .expect("listen"); + let mut c = TpClient::connect(tp.local_addr, tp.authority_pubkey) + .await + .expect("handshake"); + c.setup_connection(TDP, 2, 2, 0).await.unwrap(); + c.recv().await.expect("setup reply"); + + let coinbase = vec![0u8; usize::from(u16::MAX)]; + c.submit_solution(1, 0, 0, 0, &coinbase).await.unwrap(); + c.request_transaction_data(1).await.unwrap(); + let f = c + .recv() + .await + .expect("open after a max-size SubmitSolution"); + assert_eq!(f.msg_type, MESSAGE_TYPE_REQUEST_TRANSACTION_DATA_ERROR); + + let _ = c.send_bytes(0xff, &vec![0u8; 1 << 20]).await; + c.request_transaction_data(1).await.ok(); + let closed = tokio::time::timeout(Duration::from_secs(5), c.recv()).await; + assert!( + matches!(closed, Ok(Err(_))), + "an oversized frame must close the session, got {:?}", + closed.map(|r| r.map(|f| f.msg_type)) + ); + tp.shutdown().await; +} + +#[tokio::test] +async fn out_of_range_cert_validity_or_stale_grace_refuses_to_start() { + let tc = padded_chain("sv2-listener-range", 0); + for (cert_validity, stale_grace) in [ + (Duration::from_secs(u64::from(u32::MAX) + 1), Duration::ZERO), + ( + Duration::from_secs(3600), + MAX_STALE_GRACE + Duration::from_secs(1), + ), + ] { + let e = run_sv2_tp(Sv2TpConfig { + listen: "127.0.0.1:0".parse().unwrap(), + chain: tc.chain.clone(), + authority_secret: [7; 32], + cert_validity, + stale_grace, + setup_timeout: SETUP_TIMEOUT, + write_timeout: WRITE_TIMEOUT, + }) + .await + .err() + .expect("out-of-range config must not start"); + assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput, "{e}"); + } +} diff --git a/crates/rbitcoin-sv2/src/session.rs b/crates/rbitcoin-sv2/src/session.rs new file mode 100644 index 000000000..c1dacd93a --- /dev/null +++ b/crates/rbitcoin-sv2/src/session.rs @@ -0,0 +1,542 @@ +//! One TDP session: Noise handshake, `SetupConnection`, then TDP messages. + +use crate::template; +use crate::transport::{Frame, NoiseConn, NoiseWriter}; +use binary_sv2::{Seq064K, Str0255, B016M, B064K}; +use bitcoin::hashes::Hash; +use bitcoin::{block, Block, BlockHash, CompactTarget, Transaction, TxMerkleNode, Witness}; +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 rbitcoin_net::{BlockingRegion, ChainHub}; +use std::collections::VecDeque; +use std::io; +use std::sync::Arc; +use std::time::Duration; +use template_distribution_sv2::{ + CoinbaseOutputConstraints, RequestTransactionData, RequestTransactionDataError, + RequestTransactionDataSuccess, SubmitSolution, + ERROR_CODE_REQUEST_TRANSACTION_DATA_STALE_TEMPLATE_ID, + ERROR_CODE_REQUEST_TRANSACTION_DATA_TEMPLATE_ID_NOT_FOUND, + MESSAGE_TYPE_COINBASE_OUTPUT_CONSTRAINTS, MESSAGE_TYPE_NEW_TEMPLATE, + MESSAGE_TYPE_REQUEST_TRANSACTION_DATA, MESSAGE_TYPE_REQUEST_TRANSACTION_DATA_ERROR, + MESSAGE_TYPE_REQUEST_TRANSACTION_DATA_SUCCESS, MESSAGE_TYPE_SET_NEW_PREV_HASH, + MESSAGE_TYPE_SUBMIT_SOLUTION, +}; +use tokio::net::TcpStream; +use tokio::sync::broadcast::error::RecvError; +use tokio::sync::mpsc; +use tokio::task::JoinSet; +use tokio::time::Instant; + +const TDP_VERSION: u16 = 2; + +/// RAM trade (docs/sv2-template-provider.md): each session keeps its last +/// few templates with their full txs, so `RequestTransactionData` and +/// `SubmitSolution` do not depend on the mempool still holding them. +const MAX_RETAINED: usize = 3; + +/// CPU trade: a `CoinbaseOutputConstraints` within this long of the last +/// build waits out the rest of it, so a client cycling budgets costs at most +/// one mempool-locked build per interval; a changed budget can lag by up to +/// this long. Tip events are not delayed. +const CONSTRAINTS_COOLDOWN: Duration = Duration::from_secs(1); + +/// Budgets that replace a still-queued one before it is built. A real client +/// changes its budget minutes apart; past this many inside one cooldown the +/// session is closed so the slot goes back to a real client. +const MAX_SUPERSEDED_CONSTRAINTS: u32 = 8; + +/// The templates one session was sent. Ids are strictly increasing. +#[derive(Default)] +struct Templates { + last_id: u64, + current_prev: Option<[u8; 32]>, + /// `SetNewPrevHash.header_timestamp` sent for `current_prev`, and when. + prev_sent: Option<(u32, Instant)>, + /// `SetNewPrevHash.n_bits` and `target` sent for `current_prev`. + sent_bits: Option<(u32, [u8; 32])>, + /// A template was built since the last tip event. + built_since_tip: bool, + retained: VecDeque, +} + +struct Retained { + id: u64, + t: template::Template, + prev_sent: (u32, Instant), + /// Set once the tip moves on. + retire_at: Option, +} + +impl Templates { + fn retain(&mut self, id: u64, t: template::Template, prev_sent: (u32, Instant)) { + if self.retained.len() == MAX_RETAINED { + self.retained.pop_front(); + } + self.retained.push_back(Retained { + id, + t, + prev_sent, + retire_at: None, + }); + } + + /// Whether a tip event needs a rebuild: a new prev hash, or any build + /// since the last event. + fn on_tip(&mut self, hash: [u8; 32]) -> bool { + // ChainHub publishes the store tip (connect or rollback reconnect), + // then strips the block's txs from the mempool, then sends the event: + // a build in between can select txs the block confirmed. + std::mem::take(&mut self.built_since_tip) || self.current_prev != Some(hash) + } + + fn get(&self, id: u64) -> Option<&Retained> { + self.retained.iter().find(|r| r.id == id) + } + + /// Every retained template predates the new prev hash. + fn start_grace(&mut self, deadline: Instant) { + for r in &mut self.retained { + r.retire_at.get_or_insert(deadline); + } + } + + fn next_retire(&self) -> Option { + self.retained.iter().filter_map(|r| r.retire_at).min() + } + + fn retire(&mut self, now: Instant) { + self.retained + .retain(|r| r.retire_at.is_none_or(|d| d > now)); + } +} + +pub(crate) async fn serve( + stream: TcpStream, + responder: Box, + chain: Arc, + stale_grace: Duration, + setup_timeout: Duration, + write_timeout: Duration, +) -> io::Result<()> { + let deadline = Instant::now() + setup_timeout; + let setup = async { + let mut conn = NoiseConn::accept(stream, responder, write_timeout).await?; + let frame = conn.recv().await?; + Ok::<_, io::Error>(on_setup(&mut conn, frame).await?.then_some(conn)) + }; + let Some(conn) = tokio::time::timeout_at(deadline, setup) + .await + .map_err(|_| missed_setup_deadline())?? + else { + return Ok(()); + }; + let (mut reader, writer) = conn.into_split(); + let (frames_tx, mut frames) = mpsc::channel(1); + // Dropping the set aborts the reader when the session ends. + let mut pump = JoinSet::new(); + pump.spawn(async move { + loop { + let f = reader.recv().await; + let end = f.is_err(); + if frames_tx.send(f).await.is_err() || end { + return; + } + } + }); + let mut s = Session { + conn: writer, + chain, + stale_grace, + constraints: None, + templates: Templates::default(), + built_at: None, + rebuild_at: None, + superseded: 0, + holding: false, + held_logged: false, + }; + s.run(&mut frames, deadline).await +} + +fn missed_setup_deadline() -> io::Error { + io::Error::new(io::ErrorKind::TimedOut, "sv2: setup deadline") +} + +struct Session { + conn: NoiseWriter, + chain: Arc, + stale_grace: Duration, + /// Last `CoinbaseOutputConstraints`: `(max_additional_size, sigops)`. + constraints: Option<(u32, u16)>, + templates: Templates, + /// When `publish` last sent a template. + built_at: Option, + /// Constraints-triggered rebuild deferred by `CONSTRAINTS_COOLDOWN`. + rebuild_at: Option, + /// Budgets that replaced a queued rebuild's since the last build. + superseded: u32, + /// Last `publish` returned without a template because the node was in IBD. + /// Cleared when a template is sent. + holding: bool, + held_logged: bool, +} + +impl Session { + /// `setup_deadline` also covers the first `CoinbaseOutputConstraints`: + /// until then the session can never get a template, so it never writes + /// and the write deadline cannot free its slot. + async fn run( + &mut self, + frames: &mut mpsc::Receiver>, + setup_deadline: Instant, + ) -> io::Result<()> { + let mut tips = self.chain.subscribe_tips(); + loop { + let retire_at = self.templates.next_retire(); + tokio::select! { + f = frames.recv() => { + let Some(f) = f else { return Ok(()) }; + if !self.on_frame(f?).await? { + return Ok(()); + } + } + tip = tips.recv() => match tip { + Ok(ev) if !self.templates.on_tip(ev.hash.to_byte_array()) => {} + Ok(_) | Err(RecvError::Lagged(_)) => self.publish().await?, + Err(RecvError::Closed) => return Ok(()), + }, + _ = tokio::time::sleep_until(retire_at.unwrap_or_else(Instant::now)), + if retire_at.is_some() => + { + self.templates.retire(Instant::now()); + } + _ = tokio::time::sleep_until(self.rebuild_at.unwrap_or_else(Instant::now)), + if self.rebuild_at.is_some() => + { + self.publish().await?; + } + _ = tokio::time::sleep_until(setup_deadline), if self.constraints.is_none() => { + return Err(missed_setup_deadline()); + } + } + } + } + + /// `false`: close the session. + async fn on_frame(&mut self, mut frame: Frame) -> io::Result { + match frame.msg_type { + MESSAGE_TYPE_COINBASE_OUTPUT_CONSTRAINTS => { + let Ok(c) = binary_sv2::from_bytes::(&mut frame.payload) + else { + rbitcoin_log::info!("sv2: undecodable CoinbaseOutputConstraints"); + return Ok(false); + }; + let c = Some(( + c.coinbase_output_max_additional_size, + c.coinbase_output_max_additional_sigops, + )); + // Same budget: the last publish already applied it. A new + // budget while holding for IBD waits for the tip that leaves + // IBD; that tip calls `publish` and must not find a queued + // rebuild or a flood close in its place. + let same = c == self.constraints; + self.constraints = c; + if same || self.holding { + return Ok(true); + } + match self.built_at.map(|t| t + CONSTRAINTS_COOLDOWN) { + Some(at) if at > Instant::now() => { + if self.rebuild_at.replace(at).is_some() { + self.superseded += 1; + if self.superseded > MAX_SUPERSEDED_CONSTRAINTS { + rbitcoin_log::info!( + "sv2: CoinbaseOutputConstraints flood, closing" + ); + return Ok(false); + } + } + } + _ => self.publish().await?, + } + } + MESSAGE_TYPE_REQUEST_TRANSACTION_DATA => { + on_request_transaction_data(&mut self.conn, frame, &self.templates).await?; + } + MESSAGE_TYPE_SUBMIT_SOLUTION => self.on_submit_solution(frame).await?, + t => rbitcoin_log::info!("sv2: ignoring message {t:#x}"), + } + Ok(true) + } + + /// Build on the current tip and send it. No template while in IBD: + /// leaving IBD always comes with a new tip, which calls this again. + async fn publish(&mut self) -> io::Result<()> { + self.rebuild_at = None; + self.superseded = 0; + let Some((size, sigops)) = self.constraints else { + return Ok(()); + }; + let c = Arc::clone(&self.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)??; + let Some(mut t) = t else { + self.holding = true; + if !self.held_logged { + rbitcoin_log::info!("sv2: holding templates until the node leaves IBD"); + self.held_logged = true; + } + return Ok(()); + }; + self.holding = false; + self.templates.last_id += 1; + let template_id = self.templates.last_id; + // sv2-spec 07 §7.3: a template on a new prev hash is future, then activated. + let new_prev = self.templates.current_prev != Some(t.prev_hash); + // §7.4: nBits comes once per prev hash. On min-difficulty networks the + // build's bits follow the clock; a solution on this template is hashed + // with the bits the client was sent. + if let Some((n_bits, target)) = self.templates.sent_bits.filter(|_| !new_prev) { + t.n_bits = n_bits; + t.target = target; + } + let msg = t + .to_message(template_id, new_prev) + .map_err(|e| io::Error::other(format!("sv2 NewTemplate: {e:?}")))?; + self.conn.send(MESSAGE_TYPE_NEW_TEMPLATE, msg).await?; + self.built_at = Some(Instant::now()); + self.templates.built_since_tip = true; + if new_prev { + self.conn + .send(MESSAGE_TYPE_SET_NEW_PREV_HASH, t.to_prev_hash(template_id)) + .await?; + let now = Instant::now(); + self.templates.current_prev = Some(t.prev_hash); + self.templates.prev_sent = Some((t.header_timestamp, now)); + self.templates.sent_bits = Some((t.n_bits, t.target)); + self.templates.start_grace(now + self.stale_grace); + } + let prev_sent = self + .templates + .prev_sent + .ok_or_else(|| io::Error::other("sv2: template before SetNewPrevHash"))?; + self.templates.retain(template_id, t, prev_sent); + Ok(()) + } + + /// A bad solution is logged and dropped; a decodable one on a retained + /// template that meets its target always goes to `ChainHub::accept_block`. + async fn on_submit_solution(&mut self, mut frame: Frame) -> io::Result<()> { + let Ok(m) = binary_sv2::from_bytes::(&mut frame.payload) else { + rbitcoin_log::info!("sv2: undecodable SubmitSolution"); + return Ok(()); + }; + let Some(r) = self.templates.get(m.template_id) else { + rbitcoin_log::info!("sv2: SubmitSolution for unknown template {}", m.template_id); + return Ok(()); + }; + let Ok(mut coinbase) = + bitcoin::consensus::deserialize::(m.coinbase_tx.as_ref()) + else { + rbitcoin_log::info!("sv2: undecodable SubmitSolution coinbase"); + return Ok(()); + }; + // BIP141: a committed block's coinbase witness is exactly the reserved + // value. A client may omit it; the commitment used ours, so fill it. + // Without a commitment output a coinbase witness is invalid, so a bare + // coinbase stays bare. The txid (and the merkle root) does not cover + // the witness. + let committed = rbitcoin_consensus::witness_commitment_vout_index(&coinbase).is_some(); + if let Some(input) = coinbase.input.first_mut() { + if committed && input.witness.is_empty() { + input.witness = Witness::from_slice(&[template::WITNESS_RESERVED_VALUE]); + } + } + // CPU trade: the coinbase is leaf 0 (always the left child), so its + // txid folded over the template's path is the root the full txid + // list would give. A miss on the target costs one fold, not a clone + // and hash of every template tx plus ChainHub's connect path. + let root = rbitcoin_store::merkle_root_from_branch( + coinbase.compute_txid().to_byte_array(), + &r.t.merkle_path, + 0, + ); + let header = block::Header { + version: block::Version::from_consensus(m.version as i32), + prev_blockhash: BlockHash::from_byte_array(r.t.prev_hash), + merkle_root: TxMerkleNode::from_byte_array(root), + time: m.header_timestamp, + bits: CompactTarget::from_consensus(r.t.n_bits), + nonce: m.header_nonce, + }; + if header.validate_pow(header.target()).is_err() { + rbitcoin_log::info!( + "sv2: SubmitSolution {} misses the template target", + header.block_hash() + ); + return Ok(()); + } + // Diagnostic only: a miner clock ahead of ours rolls past the wall + // time since SetNewPrevHash. accept_block enforces the consensus + // bounds (> MTP, < now + 2h), so the block is still submitted. + let (sent_ts, sent_at) = r.prev_sent; + let rolled = + u64::try_from(sent_at.elapsed().as_millis().div_ceil(1000)).unwrap_or(u64::MAX); + if u64::from(m.header_timestamp) < u64::from(sent_ts) + || u64::from(m.header_timestamp) > u64::from(sent_ts).saturating_add(rolled) + { + rbitcoin_log::info!( + "sv2: SubmitSolution template {} header_timestamp {} outside [{sent_ts}, +{rolled}s]; submitting", + m.template_id, + m.header_timestamp + ); + } + let mut txdata = Vec::with_capacity(1 + r.t.txs.len()); + txdata.push(coinbase); + txdata.extend(r.t.txs.iter().cloned()); + let block = Block { header, txdata }; + let hash = block.block_hash(); + let c = Arc::clone(&self.chain); + let outcome = tokio::task::spawn_blocking(move || { + let _g = BlockingRegion::enter(); + c.accept_block(block) + }) + .await + .map_err(io::Error::other)?; + match outcome { + Ok(o) => rbitcoin_log::info!("sv2: SubmitSolution {hash}: {o:?}"), + Err(e) => rbitcoin_log::info!("sv2: SubmitSolution {hash} rejected: {e}"), + } + 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 +} + +/// `false`: close the session. +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(false); + } + let Ok(setup) = binary_sv2::from_bytes::(&mut frame.payload) else { + rbitcoin_log::info!("sv2: undecodable SetupConnection"); + return Ok(false); + }; + 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(false); + } + let reply = SetupConnectionSuccess { + used_version: TDP_VERSION, + flags: 0, + }; + conn.send(MESSAGE_TYPE_SETUP_CONNECTION_SUCCESS, reply) + .await?; + Ok(true) +} + +/// An id this session was sent but no longer retains is stale; any other +/// unknown id was never sent. +async fn on_request_transaction_data( + conn: &mut NoiseWriter, + mut frame: Frame, + templates: &Templates, +) -> io::Result<()> { + let Ok(RequestTransactionData { template_id }) = binary_sv2::from_bytes(&mut frame.payload) + else { + rbitcoin_log::info!("sv2: undecodable RequestTransactionData"); + return Ok(()); + }; + let Some(Retained { t, .. }) = templates.get(template_id) else { + let code = if template_id <= templates.last_id { + ERROR_CODE_REQUEST_TRANSACTION_DATA_STALE_TEMPLATE_ID + } else { + ERROR_CODE_REQUEST_TRANSACTION_DATA_TEMPLATE_ID_NOT_FOUND + }; + let reply = RequestTransactionDataError { + template_id, + error_code: Str0255::try_from(code) + .map_err(|e| io::Error::other(format!("sv2 error code: {e:?}")))?, + }; + return conn + .send(MESSAGE_TYPE_REQUEST_TRANSACTION_DATA_ERROR, reply) + .await; + }; + let raw: Vec> = t + .txs + .iter() + .map(bitcoin::consensus::encode::serialize) + .collect(); + let wire = |e: binary_sv2::Error| { + io::Error::other(format!("sv2 RequestTransactionData.Success: {e:?}")) + }; + let reply = RequestTransactionDataSuccess { + template_id, + excess_data: B064K::try_from(&[][..]).map_err(wire)?, + transaction_list: Seq064K::new( + raw.iter() + .map(|tx| B016M::try_from(&tx[..])) + .collect::>() + .map_err(wire)?, + ) + .map_err(wire)?, + }; + conn.send(MESSAGE_TYPE_REQUEST_TRANSACTION_DATA_SUCCESS, reply) + .await +} + +#[cfg(test)] +mod tests { + use super::Templates; + + #[test] + fn tip_event_rebuilds_a_template_built_before_it() { + let (old, new) = ([1; 32], [2; 32]); + let mut t = Templates::default(); + assert!(t.on_tip(old), "first tip"); + t.current_prev = Some(old); + assert!(!t.on_tip(old), "repeat event with no build since"); + // A build between the store tip move and its event (constraints, or + // the previous event's rebuild) already sits on the new prev hash. + t.current_prev = Some(new); + t.built_since_tip = true; + assert!(t.on_tip(new), "template may hold the block's confirmed txs"); + assert!(!t.on_tip(new), "no build since the event"); + // A failed reorg disconnects `new` without an event, then its rollback + // reconnects `new`; a build in that window precedes the second event. + t.built_since_tip = true; + assert!(t.on_tip(new), "reconnect of the same hash after a build"); + } +} diff --git a/crates/rbitcoin-sv2/src/template.rs b/crates/rbitcoin-sv2/src/template.rs new file mode 100644 index 000000000..da192172c --- /dev/null +++ b/crates/rbitcoin-sv2/src/template.rs @@ -0,0 +1,138 @@ +//! 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, Target, Transaction, TxOut}; +use rbitcoin_consensus::{ + bip34_height_script, block_subsidy, expected_next_bits, median_time_past, + witness_commitment_script, MAX_BLOCK_WEIGHT, +}; +use rbitcoin_net::{ChainHub, SelectBudget}; +use rbitcoin_primitives::Height; +use std::io; +use template_distribution_sv2::{NewTemplate, SetNewPrevHash}; + +/// Coinbase witness reserved value the template's witness commitment is +/// built with (BIP141). +pub(crate) const WITNESS_RESERVED_VALUE: [u8; 32] = [0u8; 32]; + +/// 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]>, + 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], + /// Non-coinbase txs in block order, kept past mempool eviction. + pub txs: Vec, +} + +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())?, + }) + } + + 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 and reads the store: blocking region only. +pub(crate) fn build( + chain: &ChainHub, + max_additional_size: u32, + max_additional_sigops: u16, +) -> io::Result