From f2550ef581dd1321608b4079d1f3d03ea2e423ac Mon Sep 17 00:00:00 2001 From: zancas Date: Sun, 23 Aug 2026 14:39:17 -0700 Subject: [PATCH] feat(zcash_local_net)!: prefetch block templates via getblocktemplate long polling Profiling zingolib's send_shield_cycle round trip showed that every generated regtest block pays about 2.2 seconds inside zebrad's getblocktemplate handler, which constructs a fresh shielded coinbase transaction, proof included, for each template. Zebra already owns the remedy: a getblocktemplate call that presents the previous template's longpollid makes zebrad precompute the next coinbase while it waits for the tip to change, and answer the tip change with a provisional empty template built from that precomputation. Zebrad::generate_blocks now parks exactly that long-poll request after each mined block, so zebrad performs the coinbase proof work while the calling test syncs wallets and runs assertions. The next generate_blocks call consumes the prefetched template only when three guards hold: the mempool is empty, the template's height is the target height, and the template carries no transactions. Any other state aborts the prefetch and falls back to the previous fetch-fresh path, which preserves the documented contract that generated blocks confirm the transactions the server has received. Measured on send_shield_cycle, empty separation blocks drop from about 2.5 seconds to between 0.4 and 1.6 seconds, and the fixture's total mining cost falls from about 28 to 23 seconds. BlockTemplate gains the long_poll_id field, and submit_template_block splits into fetch_block_template, fetch_block_template_long_poll, and submit_block_from_template, with mempool_txids exposing the gate's mempool probe. The field addition is the breaking surface, recorded in the crate CHANGELOG. Co-Authored-By: Claude Fable 5 --- zcash_local_net/CHANGELOG.md | 22 ++++++++ zcash_local_net/src/validator/zebrad.rs | 73 ++++++++++++++++++++++++- zcash_local_net/src/zebra_rpc.rs | 47 +++++++++++++++- 3 files changed, 136 insertions(+), 6 deletions(-) diff --git a/zcash_local_net/CHANGELOG.md b/zcash_local_net/CHANGELOG.md index 9dea7b5..87b8012 100644 --- a/zcash_local_net/CHANGELOG.md +++ b/zcash_local_net/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING: `BlockTemplate` gained a `long_poll_id` field.** The + field deserializes from `getblocktemplate`'s `longpollid`, so any + downstream literal construction of `BlockTemplate` must now populate + it. It feeds the new template prefetch in `Zebrad::generate_blocks`: + after each mined block the harness parks a long-poll + `getblocktemplate` on zebrad, which precomputes the next shielded + coinbase (about two seconds of proof work) while the harness does + other work, and the next `generate_blocks` call consumes the + prefetched template instead of paying that construction inline. The + prefetched template is an empty provisional block, so it is consumed + only when the mempool is empty, the height matches, and the template + carries no transactions; every other case falls back to the previous + fetch-fresh path, which keeps the documented contract that generated + blocks confirm mempool transactions. Measured on zingolib's + `send_shield_cycle` round trip, empty separation blocks drop from + about 2.5 seconds to 0.4–1.6 seconds each. `submit_template_block` + split into the public `fetch_block_template`, + `fetch_block_template_long_poll`, and `submit_block_from_template` + stages, and `mempool_txids` exposes the gate's mempool probe. + ### Added - **Containerized artifacts.** Every managed process (the zebrad diff --git a/zcash_local_net/src/validator/zebrad.rs b/zcash_local_net/src/validator/zebrad.rs index 5e1076d..78e05a2 100644 --- a/zcash_local_net/src/validator/zebrad.rs +++ b/zcash_local_net/src/validator/zebrad.rs @@ -16,6 +16,7 @@ use zingo_test_vectors::{ use std::{net::SocketAddr, path::PathBuf, process::Child}; use crate::rpc_client::RpcRequestClient; +use crate::zebra_rpc::BlockTemplate; use tempfile::TempDir; /// Zebrad configuration @@ -188,6 +189,10 @@ pub struct Zebrad { client: RpcRequestClient, /// Network type network: NetworkType, + /// In-flight long-poll `getblocktemplate` request spawned after the + /// last mined block, whose response `generate_blocks` may consume as + /// the next block's template when the mempool is empty. + template_prefetch: std::sync::Mutex>>>, } crate::macros::ref_getters!(Zebrad { @@ -598,6 +603,7 @@ impl Zebrad { logs_dir, data_dir, client, + template_prefetch: std::sync::Mutex::new(None), network: config.network_type, }; @@ -640,6 +646,55 @@ impl Zebrad { /// only entry. const ZEBRAD_RPC_LISTENER_INDEX: usize = 0; +impl Zebrad { + /// Ceiling on waiting for a prefetched template's long poll to resolve, sized well above zebrad's observed ~2s shielded-coinbase precompute so a healthy response is never abandoned while a wedged one cannot stall mining. + const PREFETCH_RESOLUTION_CEILING: std::time::Duration = std::time::Duration::from_secs(10); + + /// Spawns a long-poll `getblocktemplate` request presenting `long_poll_id`, storing the task so the next `generate_blocks` call can consume its response, and aborting any stale predecessor. + fn spawn_template_prefetch(&self, long_poll_id: String) { + let client = self.client.clone(); + let handle = tokio::spawn(async move { + crate::zebra_rpc::fetch_block_template_long_poll(&client, &long_poll_id) + .await + .ok() + }); + if let Some(stale) = self + .template_prefetch + .lock() + .expect("template prefetch lock must not be poisoned") + .replace(handle) + { + stale.abort(); + } + } + + /// Takes the pending prefetched template when it is safe to mine from it: zebra answers a fired long poll with an empty provisional block, so the template is consumed only when the mempool is empty, it templates exactly `target_height`, and it carries no transactions. + async fn take_prefetched_template(&self, target_height: u32) -> Option { + let mut handle = self + .template_prefetch + .lock() + .expect("template prefetch lock must not be poisoned") + .take()?; + match crate::zebra_rpc::mempool_txids(&self.client).await { + Ok(txids) if txids.is_empty() => {} + _ => { + handle.abort(); + return None; + } + } + let template = + match tokio::time::timeout(Self::PREFETCH_RESOLUTION_CEILING, &mut handle).await { + Ok(Ok(Some(template))) => template, + Ok(_) => return None, + Err(_elapsed) => { + handle.abort(); + return None; + } + }; + (template.height == target_height && template.transactions.is_empty()).then_some(template) + } +} + impl crate::backend::Backend for Zebrad { fn log_text(&self) -> std::io::Result { std::fs::read_to_string(self.logs_dir.path().join(crate::logs::STDOUT_LOG)) @@ -762,17 +817,29 @@ impl Validator for Zebrad { const ATTEMPT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100); for i in 0..n { let target_height = chain_height + i + 1; + let mut prefetched = self.take_prefetched_template(target_height).await; let mut last_response = String::new(); let mut advanced = false; for _ in 0..MAX_ATTEMPTS { - let submission = - crate::zebra_rpc::submit_template_block(&self.client, activation_heights) + let template = match prefetched.take() { + Some(template) => template, + None => crate::zebra_rpc::fetch_block_template(&self.client) .await - .expect("template block submission should succeed"); + .expect("template fetch should succeed"), + }; + let long_poll_id = template.long_poll_id.clone(); + let submission = crate::zebra_rpc::submit_block_from_template( + &self.client, + &template, + activation_heights, + ) + .await + .expect("template block submission should succeed"); last_response = submission.response; if self.get_chain_height().await >= target_height { advanced = true; + self.spawn_template_prefetch(long_poll_id); break; } tokio::time::sleep(ATTEMPT_INTERVAL).await; diff --git a/zcash_local_net/src/zebra_rpc.rs b/zcash_local_net/src/zebra_rpc.rs index 5e7d898..568a8c0 100644 --- a/zcash_local_net/src/zebra_rpc.rs +++ b/zcash_local_net/src/zebra_rpc.rs @@ -68,6 +68,9 @@ pub struct BlockTemplate { pub coinbase_txn: TransactionTemplate, /// The non-coinbase transactions. pub transactions: Vec, + /// The template's long-poll id, which a follow-up `getblocktemplate` call can present to wait server-side for the next chain state instead of paying template construction inline. + #[serde(rename = "longpollid")] + pub long_poll_id: String, } /// The header roots from a template's `defaultroots` field. @@ -211,10 +214,39 @@ pub async fn submit_template_block( client: &crate::rpc_client::RpcRequestClient, activation_heights: &ActivationHeights, ) -> Result { - let template: BlockTemplate = client + let template = fetch_block_template(client).await?; + submit_block_from_template(client, &template, activation_heights).await +} + +/// Fetches a fresh block template for the current chain tip. +pub async fn fetch_block_template( + client: &crate::rpc_client::RpcRequestClient, +) -> Result { + Ok(client .json_result_from_call("getblocktemplate", "[]".to_string()) - .await?; - let block_bytes = proposal_block_bytes(&template, activation_heights)?; + .await?) +} + +/// Fetches a block template by presenting `long_poll_id`, so zebrad precomputes the next template's shielded coinbase while it waits for the tip to change instead of building it inline on the next fresh fetch. +pub async fn fetch_block_template_long_poll( + client: &crate::rpc_client::RpcRequestClient, + long_poll_id: &str, +) -> Result { + Ok(client + .json_result_from_call( + "getblocktemplate", + format!(r#"[{{"longpollid":"{long_poll_id}"}}]"#), + ) + .await?) +} + +/// Assembles `template` into a block proposal and submits it. +pub async fn submit_block_from_template( + client: &crate::rpc_client::RpcRequestClient, + template: &BlockTemplate, + activation_heights: &ActivationHeights, +) -> Result { + let block_bytes = proposal_block_bytes(template, activation_heights)?; let block_hash = block_hash_hex(&block_bytes); let block_hex = hex::encode(&block_bytes); let response = client @@ -227,6 +259,15 @@ pub async fn submit_template_block( }) } +/// Returns the txids currently in zebrad's mempool. +pub async fn mempool_txids( + client: &crate::rpc_client::RpcRequestClient, +) -> Result, SubmitBlockError> { + Ok(client + .json_result_from_call("getrawmempool", "[]".to_string()) + .await?) +} + /// Decode a hex template field, mapping failure to [`ZebraRpcError::InvalidHex`]. fn decode_hex(field: &'static str, hex_str: &str) -> Result, ZebraRpcError> { hex::decode(hex_str).map_err(|e| ZebraRpcError::InvalidHex {