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 {