Repository: ZcashFoundation/zebra (rust)

Issue:
Add `valuePools` object to `getblockchaininfo` RPC
In https://github.com/ZcashFoundation/zebra/pull/8765 we tried to add `valuePools` object to the `getblock` RPC method but we realized we can't do it without changing the database which we prefer to defer. https://github.com/ZcashFoundation/zebra/issues/8730

However, the [getblockchaininfo](https://zcash.github.io/rpc/getblockchaininfo.html) RPC method provides value pool information for the tip, something that we can provide using similar code introduced in https://github.com/ZcashFoundation/zebra/pull/8765

Pull request:
change(rpc): Add value pool balances to `getblockchaininfo` RPC method response
## Motivation

We want to add value pool balances at the chain tip to Zebra's `getblockchaininfo` RPC method response.

Closes #8767.

### Specifications & References

https://zcash.github.io/rpc/getblockchaininfo.html

## Solution

- Moves the `Zec` type in `zebra-rpc` out from behind the `getblocktemplate-rpcs` feature flag.
- Adds a `ValuePoolBalance` type to a new `types::get_blockchain_info` module with a fn for creating one from a `ValueBalance`.
- Updates the `getblockchaininfo` RPC method return type to a `BoxFuture` to make async calls to the state service.
- Adds a new state service request for getting the tip height, tip hash, and the value balance.
  - Checks that the tip hasn't changed while reading the value balance from the finalized state. This could cause the RPC method to return an error after a few retries, which could be avoided by adding the value balance information to the `ChainTipBlock` instead of adding a new state service request, but in practice, the tip and value balance should be in-memory when there is no non-finalized best chain and blocks are being committed rapidly, so it should be unlikely to return that error. Still, let me know if we want to add it to the `ChainTipBlock` instead, we could do that here or in a follow-up PR.
- Updates the `getblockchaininfo` method to get the chain tip height, hash, value balance, and block time from state requests.
- Updates snapshots

Related Changes:
- Minor refactor of `estimated_height` assignment
- Uses generic error construction in the last couple places where possible and removes the outdated TODOs

### Tests

Updates existing snapshots (and some prop tests to handle the `BoxFuture` wrapped return type).

### PR Author's Checklist

<!-- If you are the author of the PR, check the boxes below before making the PR
ready for review. -->

- [x] The PR name will make sense to users.
- [ ] The PR provides a CHANGELOG summary.
- [x] The solution is tested.
- [x] The documentation is up to date.
- [x] The PR has a priority label.

### PR Reviewer's Checklist

<!-- If you are a reviewer of the PR, check the boxes below before approving it. -->

- [ ] The PR Author's checklist is complete.
- [ ] The PR resolves the issue.



Patch:
diff --git a/zebra-chain/src/chain_tip.rs b/zebra-chain/src/chain_tip.rs
index c19c556e7..04e98ecbf 100644
--- a/zebra-chain/src/chain_tip.rs
+++ b/zebra-chain/src/chain_tip.rs
@@ -14,7 +14,7 @@ pub mod mock;
 #[cfg(test)]
 mod tests;
 
-use network_chain_tip_height_estimator::NetworkChainTipHeightEstimator;
+pub use network_chain_tip_height_estimator::NetworkChainTipHeightEstimator;
 
 /// An interface for querying the chain tip.
 ///
diff --git a/zebra-rpc/src/methods.rs b/zebra-rpc/src/methods.rs
index c17a742a5..ae5deb7a5 100644
--- a/zebra-rpc/src/methods.rs
+++ b/zebra-rpc/src/methods.rs
@@ -21,7 +21,7 @@ use tracing::Instrument;
 use zcash_primitives::consensus::Parameters;
 use zebra_chain::{
     block::{self, Height, SerializedBlock},
-    chain_tip::ChainTip,
+    chain_tip::{ChainTip, NetworkChainTipHeightEstimator},
     parameters::{ConsensusBranchId, Network, NetworkUpgrade},
     serialization::ZcashDeserialize,
     subtree::NoteCommitmentSubtreeIndex,
@@ -45,6 +45,8 @@ use errors::{MapServerError, OkOrServerError};
 // We don't use a types/ module here, because it is redundant.
 pub mod trees;
 
+pub mod types;
+
 #[cfg(feature = "getblocktemplate-rpcs")]
 pub mod get_block_template_rpcs;
 
@@ -85,7 +87,7 @@ pub trait Rpc {
     /// Some fields from the zcashd reference are missing from Zebra's [`GetBlockChainInfo`]. It only contains the fields
     /// [required for lightwalletd support.](https://github.com/zcash/lightwalletd/blob/v0.4.9/common/common.go#L72-L89)
     #[rpc(name = "getblockchaininfo")]
-    fn get_blockchain_info(&self) -> Result<GetBlockChainInfo>;
+    fn get_blockchain_info(&self) -> BoxFuture<Result<GetBlockChainInfo>>;
 
     /// Returns the total balance of a provided `addresses` in an [`AddressBalance`] instance.
     ///
@@ -500,98 +502,120 @@ where
         Ok(response)
     }
 
-    // TODO: use a generic error constructor (#5548)
     #[allow(clippy::unwrap_in_result)]
-    fn get_blockchain_info(&self) -> Result<GetBlockChainInfo> {
-        let network = &self.network;
+    fn get_blockchain_info(&self) -> BoxFuture<Result<GetBlockChainInfo>> {
+        let network = self.network.clone();
+        let debug_force_finished_sync = self.debug_force_finished_sync;
+        let mut state = self.state.clone();
 
-        // `chain` field
-        let chain = self.network.bip70_network_name();
+        async move {
+            // `chain` field
+            let chain = network.bip70_network_name();
 
-        // `blocks` and `best_block_hash` fields
-        let (tip_height, tip_hash) = self
-            .latest_chain_tip
-            .best_tip_height_and_hash()
-            .ok_or_server_error("No Chain tip available yet")?;
+            let request = zebra_state::ReadRequest::TipPoolValues;
+            let response: zebra_state::ReadResponse = state
+                .ready()
+                .and_then(|service| service.call(request))
+                .await
+                .map_server_error()?;
+
+            let zebra_state::ReadResponse::TipPoolValues {
+                tip_height,
+                tip_hash,
+                value_balance,
+            } = response
+            else {
+                unreachable!("unmatched response to a TipPoolValues request")
+            };
 
-        // `estimated_height` field
-        let current_block_time = self
-            .latest_chain_tip
-            .best_tip_block_time()
-            .ok_or_server_error("No Chain tip available yet")?;
+            let request = zebra_state::ReadRequest::BlockHeader(tip_hash.into());
+            let response: zebra_state::ReadResponse = state
+                .ready()
+                .and_then(|service| service.call(request))
+                .await
+                .map_server_error()?;
 
-        let zebra_estimated_height = self
-            .latest_chain_tip
-            .estimate_network_chain_tip_height(network, Utc::now())
-            .ok_or_server_error("No Chain tip available yet")?;
+            let zebra_state::ReadResponse::BlockHeader(block_header) = response else {
+                unreachable!("unmatched response to a BlockHeader request")
+            };
 
-        let mut estimated_height =
-            if current_block_time > Utc::now() || zebra_estimated_height < tip_height {
+            let tip_block_time = block_header
+                .ok_or_server_error("unexpectedly could not read best chain tip block header")?
+                .time;
+
+            let now = Utc::now();
+            let zebra_estimated_height =
+                NetworkChainTipHeightEstimator::new(tip_block_time, tip_height, &network)
+                    .estimate_height_at(now);
+
+            // If we're testing the mempool, force the estimated height to be the actual tip height, otherwise,
+            // check if the estimated height is below Zebra's latest tip height, or if the latest tip's block time is
+            // later than the current time on the local clock.
+            let estimated_height = if tip_block_time > now
+                || zebra_estimated_height < tip_height
+                || debug_force_finished_sync
+            {
                 tip_height
             } else {
                 zebra_estimated_height
             };
 
-        // If we're testing the mempool, force the estimated height to be the actual tip height.
-        if self.debug_force_finished_sync {
-            estimated_height = tip_height;
-        }
-
-        // `upgrades` object
-        //
-        // Get the network upgrades in height order, like `zcashd`.
-        let mut upgrades = IndexMap::new();
-        for (activation_height, network_upgrade) in network.full_activation_list() {
-            // Zebra defines network upgrades based on incompatible consensus rule changes,
-            // but zcashd defines them based on ZIPs.
+            // `upgrades` object
             //
-            // All the network upgrades with a consensus branch ID are the same in Zebra and zcashd.
-            if let Some(branch_id) = network_upgrade.branch_id() {
-                // zcashd's RPC seems to ignore Disabled network upgrades, so Zebra does too.
-                let status = if tip_height >= activation_height {
-                    NetworkUpgradeStatus::Active
-                } else {
-                    NetworkUpgradeStatus::Pending
-                };
+            // Get the network upgrades in height order, like `zcashd`.
+            let mut upgrades = IndexMap::new();
+            for (activation_height, network_upgrade) in network.full_activation_list() {
+                // Zebra defines network upgrades based on incompatible consensus rule changes,
+                // but zcashd defines them based on ZIPs.
+                //
+                // All the network upgrades with a consensus branch ID are the same in Zebra and zcashd.
+                if let Some(branch_id) = network_upgrade.branch_id() {
+                    // zcashd's RPC seems to ignore Disabled network upgrades, so Zebra does too.
+                    let status = if tip_height >= activation_height {
+                        NetworkUpgradeStatus::Active
+                    } else {
+                        NetworkUpgradeStatus::Pending
+                    };
 
-                let upgrade = NetworkUpgradeInfo {
-                    name: network_upgrade,
-                    activation_height,
-                    status,
-                };
-                upgrades.insert(ConsensusBranchIdHex(branch_id), upgrade);
+                    let upgrade = NetworkUpgradeInfo {
+                        name: network_upgrade,
+                        activation_height,
+                        status,
+                    };
+                    upgrades.insert(ConsensusBranchIdHex(branch_id), upgrade);
+                }
             }
-        }
 
-        // `consensus` object
-        let next_block_height =
-            (tip_height + 1).expect("valid chain tips are a lot less than Height::MAX");
-        let consensus = TipConsensusBranch {
-            chain_tip: ConsensusBranchIdHex(
-                NetworkUpgrade::current(network, tip_height)
-                    .branch_id()
-                    .unwrap_or(ConsensusBranchId::RPC_MISSING_ID),
-            ),
-            next_block: ConsensusBranchIdHex(
-                NetworkUpgrade::current(network, next_block_height)
-                    .branch_id()
-                    .unwrap_or(ConsensusBranchId::RPC_MISSING_ID),
-            ),
-        };
+            // `consensus` object
+            let next_block_height =
+                (tip_height + 1).expect("valid chain tips are a lot less than Height::MAX");
+            let consensus = TipConsensusBranch {
+                chain_tip: ConsensusBranchIdHex(
+                    NetworkUpgrade::current(&network, tip_height)
+                        .branch_id()
+                        .unwrap_or(ConsensusBranchId::RPC_MISSING_ID),
+                ),
+                next_block: ConsensusBranchIdHex(
+                    NetworkUpgrade::current(&network, next_block_height)
+                        .branch_id()
+                        .unwrap_or(ConsensusBranchId::RPC_MISSING_ID),
+                ),
+            };
 
-        let response = GetBlockChainInfo {
-            chain,
-            blocks: tip_height,
-            best_block_hash: tip_hash,
-            estimated_height,
-            upgrades,
-            consensus,
-        };
+            let response = GetBlockChainInfo {
+                chain,
+                blocks: tip_height,
+                best_block_hash: tip_hash,
+                estimated_height,
+                value_pools: types::ValuePoolBalance::from_value_balance(value_balance),
+                upgrades,
+                consensus,
+            };
 
-        Ok(response)
+            Ok(response)
+        }
+        .boxed()
     }
-
-    // TODO: use a generic error constructor (#5548)
     fn get_address_balance(
         &self,
         address_strings: AddressStrings,
@@ -615,7 +639,6 @@ where
     }
 
     // TODO: use HexData or GetRawTransaction::Bytes to handle the transaction data argument
-    //       use a generic error constructor (#5548)
     fn send_raw_transaction(
         &self,
         raw_transaction_hex: String,
@@ -963,7 +986,6 @@ where
     }
 
     // TODO: use HexData or SentTransactionHash to handle the transaction ID
-    //       use a generic error constructor (#5548)
     fn get_raw_transaction(
         &self,
         txid_hex: String,
@@ -1197,7 +1219,6 @@ where
         .boxed()
     }
 
-    // TODO: use a generic error constructor (#5548)
     fn get_address_tx_ids(
         &self,
         request: GetAddressTxIdsRequest,
@@ -1258,7 +1279,6 @@ where
         .boxed()
     }
 
-    // TODO: use a generic error constructor (#5548)
     fn get_address_utxos(
         &self,
         address_strings: AddressStrings,
@@ -1372,6 +1392,10 @@ pub struct GetBlockChainInfo {
     #[serde(rename = "estimatedheight")]
     estimated_height: Height,
 
+    /// Value pool balances
+    #[serde(rename = "valuePools")]
+    value_pools: [types::ValuePoolBalance; 5],
+
     /// Status of network upgrades
     upgrades: IndexMap<ConsensusBranchIdHex, NetworkUpgradeInfo>,
 
@@ -1386,6 +1410,7 @@ impl Default for GetBlockChainInfo {
             blocks: Height(1),
             best_block_hash: block::Hash([0; 32]),
             estimated_height: Height(1),
+            value_pools: types::ValuePoolBalance::zero_pools(),
             upgrades: IndexMap::new(),
             consensus: TipConsensusBranch {
                 chain_tip: ConsensusBranchIdHex(ConsensusBranchId::default()),
diff --git a/zebra-rpc/src/methods/get_block_template_rpcs.rs b/zebra-rpc/src/methods/get_block_template_rpcs.rs
index 464018979..c8c83e931 100644
--- a/zebra-rpc/src/methods/get_block_template_rpcs.rs
+++ b/zebra-rpc/src/methods/get_block_template_rpcs.rs
@@ -551,7 +551,6 @@ where
         best_chain_tip_height(&self.latest_chain_tip).map(|height| height.0)
     }
 
-    // TODO: use a generic error constructor (#5548)
     fn get_block_hash(&self, index: i32) -> BoxFuture<Result<GetBlockHash>> {
         let mut state = self.state.clone();
         let latest_chain_tip = self.latest_chain_tip.clone();
@@ -567,11 +566,7 @@ where
                 .ready()
                 .and_then(|service| service.call(request))
                 .await
-                .map_err(|error| Error {
-                    code: ErrorCode::ServerError(0),
-                    message: error.to_string(),
-                    data: None,
-                })?;
+                .map_server_error()?;
 
             match response {
                 zebra_state::ReadResponse::BlockHash(Some(hash)) => Ok(GetBlockHash(hash)),
@@ -586,7 +581,6 @@ where
         .boxed()
     }
 
-    // TODO: use a generic error constructor (#5548)
     fn get_block_template(
         &self,
         parameters: Option<get_block_template::JsonParameters>,
@@ -830,11 +824,7 @@ where
                                     Is Zebra shutting down?"
                                 );
 
-                                return Err(Error {
-                                    code: ErrorCode::ServerError(0),
-                                    message: recv_error.to_string(),
-                                    data: None,
-                                });
+                                return Err(recv_error).map_server_error();
                             }
                         }
                     }
diff --git a/zebra-rpc/src/methods/get_block_template_rpcs/types.rs b/zebra-rpc/src/methods/get_block_template_rpcs/types.rs
index a2f5ccc26..41046f7b2 100644
--- a/zebra-rpc/src/methods/get_block_template_rpcs/types.rs
+++ b/zebra-rpc/src/methods/get_block_template_rpcs/types.rs
@@ -11,4 +11,3 @@ pub mod transaction;
 pub mod unified_address;
 pub mod validate_address;
 pub mod z_validate_address;
-pub mod zec;
diff --git a/zebra-rpc/src/methods/get_block_template_rpcs/types/subsidy.rs b/zebra-rpc/src/methods/get_block_template_rpcs/types/subsidy.rs
index d7e491de5..6d64dcbde 100644
--- a/zebra-rpc/src/methods/get_block_template_rpcs/types/subsidy.rs
+++ b/zebra-rpc/src/methods/get_block_template_rpcs/types/subsidy.rs
@@ -6,7 +6,7 @@ use zebra_chain::{
     transparent,
 };
 
-use crate::methods::get_block_template_rpcs::types::zec::Zec;
+use crate::methods::types::Zec;
 
 /// A response to a `getblocksubsidy` RPC request
 #[derive(Clone, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
diff --git a/zebra-rpc/src/methods/types.rs b/zebra-rpc/src/methods/types.rs
new file mode 100644
index 000000000..d29e697f0
--- /dev/null
+++ b/zebra-rpc/src/methods/types.rs
@@ -0,0 +1,7 @@
+//! Types used in RPC methods.
+
+mod get_blockchain_info;
+mod zec;
+
+pub use get_blockchain_info::ValuePoolBalance;
+pub use zec::Zec;
diff --git a/zebra-rpc/src/methods/types/get_blockchain_info.rs b/zebra-rpc/src/methods/types/get_blockchain_info.rs
new file mode 100644
index 000000000..a2d1e7816
--- /dev/null
+++ b/zebra-rpc/src/methods/types/get_blockchain_info.rs
@@ -0,0 +1,72 @@
+//! Types used in `getblockchaininfo` RPC method.
+
+use zebra_chain::{
+    amount::{Amount, NonNegative},
+    value_balance::ValueBalance,
+};
+
+use super::*;
+
+/// A value pool's balance in Zec and Zatoshis
+#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ValuePoolBalance {
+    /// Name of the pool
+    id: String,
+    /// Total amount in the pool, in ZEC
+    chain_value: Zec<NonNegative>,
+    /// Total amount in the pool, in zatoshis
+    chain_value_zat: Amount<NonNegative>,
+}
+
+impl ValuePoolBalance {
+    /// Returns a list of [`ValuePoolBalance`]s converted from the default [`ValueBalance`].
+    pub fn zero_pools() -> [Self; 5] {
+        Self::from_value_balance(Default::default())
+    }
+
+    /// Creates a new [`ValuePoolBalance`] from a pool name and its value balance.
+    pub fn new(id: impl ToString, amount: Amount<NonNegative>) -> Self {
+        Self {
+            id: id.to_string(),
+            chain_value: Zec::from(amount),
+            chain_value_zat: amount,
+        }
+    }
+
+    /// Creates a [`ValuePoolBalance`] for the transparent pool.
+    pub fn transparent(amount: Amount<NonNegative>) -> Self {
+        Self::new("transparent", amount)
+    }
+
+    /// Creates a [`ValuePoolBalance`] for the Sprout pool.
+    pub fn sprout(amount: Amount<NonNegative>) -> Self {
+        Self::new("sprout", amount)
+    }
+
+    /// Creates a [`ValuePoolBalance`] for the Sapling pool.
+    pub fn sapling(amount: Amount<NonNegative>) -> Self {
+        Self::new("sapling", amount)
+    }
+
+    /// Creates a [`ValuePoolBalance`] for the Orchard pool.
+    pub fn orchard(amount: Amount<NonNegative>) -> Self {
+        Self::new("orchard", amount)
+    }
+
+    /// Creates a [`ValuePoolBalance`] for the Deferred pool.
+    pub fn deferred(amount: Amount<NonNegative>) -> Self {
+        Self::new("deferred", amount)
+    }
+
+    /// Converts a [`ValueBalance`] to a list of [`ValuePoolBalance`]s.
+    pub fn from_value_balance(value_balance: ValueBalance<NonNegative>) -> [Self; 5] {
+        [
+            Self::transparent(value_balance.transparent_amount()),
+            Self::sprout(value_balance.sprout_amount()),
+            Self::sapling(value_balance.sapling_amount()),
+            Self::orchard(value_balance.orchard_amount()),
+            Self::deferred(value_balance.deferred_amount()),
+        ]
+    }
+}
diff --git a/zebra-rpc/src/methods/get_block_template_rpcs/types/zec.rs b/zebra-rpc/src/methods/types/zec.rs
similarity index 100%
rename from zebra-rpc/src/methods/get_block_template_rpcs/types/zec.rs
rename to zebra-rpc/src/methods/types/zec.rs
diff --git a/zebra-state/src/request.rs b/zebra-state/src/request.rs
index 338c85301..28740a336 100644
--- a/zebra-state/src/request.rs
+++ b/zebra-state/src/request.rs
@@ -815,6 +815,10 @@ pub enum ReadRequest {
     /// with the current best chain tip.
     Tip,
 
+    /// Returns [`ReadResponse::TipPoolValues(Option<(Height, block::Hash, ValueBalance)>)`](ReadResponse::TipPoolValues)
+    /// with the current best chain tip.
+    TipPoolValues,
+
     /// Computes the depth in the current best chain of the block identified by the given hash.
     ///
     /// Returns
@@ -1065,6 +1069,7 @@ impl ReadRequest {
     fn variant_name(&self) -> &'static str {
         match self {
             ReadRequest::Tip => "tip",
+            ReadRequest::TipPoolValues => "tip_pool_values",
             ReadRequest::Depth(_) => "depth",
             ReadRequest::Block(_) => "block",
             ReadRequest::BlockHeader(_) => "block_header",
diff --git a/zebra-state/src/response.rs b/zebra-state/src/response.rs
index 242191f82..22e610838 100644
--- a/zebra-state/src/response.rs
+++ b/zebra-state/src/response.rs
@@ -10,6 +10,7 @@ use zebra_chain::{
     subtree::{NoteCommitmentSubtreeData, NoteCommitmentSubtreeIndex},
     transaction::{self, Transaction},
     transparent,
+    value_balance::ValueBalance,
 };
 
 #[cfg(feature = "getblocktemplate-rpcs")]
@@ -128,6 +129,17 @@ pub enum ReadResponse {
     /// Response to [`ReadRequest::Tip`] with the current best chain tip.
     Tip(Option<(block::Height, block::Hash)>),
 
+    /// Response to [`ReadRequest::TipPoolValues`] with
+    /// the current best chain tip and its [`ValueBalance`].
+    TipPoolValues {
+        /// The current best chain tip height.
+        tip_height: block::Height,
+        /// The current best chain tip hash.
+        tip_hash: block::Hash,
+        /// The value pool balance at the current best chain tip.
+        value_balance: ValueBalance<NonNegative>,
+    },
+
     /// Response to [`ReadRequest::Depth`] with the depth of the specified block.
     Depth(Option<u32>),
 
@@ -287,7 +299,8 @@ impl TryFrom<ReadResponse> for Response {
 
             ReadResponse::ValidBestChainTipNullifiersAndAnchors => Ok(Response::ValidBestChainTipNullifiersAndAnchors),
 
-            ReadResponse::TransactionIdsForBlock(_)
+            ReadResponse::TipPoolValues { .. }
+            | ReadResponse::TransactionIdsForBlock(_)
             | ReadResponse::SaplingTree(_)
             | ReadResponse::OrchardTree(_)
             | ReadResponse::SaplingSubtrees(_)
diff --git a/zebra-state/src/service.rs b/zebra-state/src/service.rs
index 0fbe8d8ea..2116ab104 100644
--- a/zebra-state/src/service.rs
+++ b/zebra-state/src/service.rs
@@ -1192,6 +1192,38 @@ impl Service<ReadRequest> for ReadStateService {
                 .wait_for_panics()
             }
 
+            // Used by `getblockchaininfo` RPC method.
+            ReadRequest::TipPoolValues => {
+                let state = self.clone();
+
+                tokio::task::spawn_blocking(move || {
+                    span.in_scope(move || {
+                        let tip_with_value_balance = state
+                            .non_finalized_state_receiver
+                            .with_watch_data(|non_finalized_state| {
+                                read::tip_with_value_balance(
+                                    non_finalized_state.best_chain(),
+                                    &state.db,
+                                )
+                            });
+
+                        // The work is done in the future.
+                        // TODO: Do this in the Drop impl with the variant name?
+                        timer.finish(module_path!(), line!(), "ReadRequest::TipPoolValues");
+
+                        let (tip_height, tip_hash, value_balance) = tip_with_value_balance?
+                            .ok_or(BoxError::from("no chain tip available yet"))?;
+
+                        Ok(ReadResponse::TipPoolValues {
+                            tip_height,
+                            tip_hash,
+                            value_balance,
+                        })
+                    })
+                })
+                .wait_for_panics()
+            }
+
             // Used by the StateService.
             ReadRequest::Depth(hash) => {
                 let state = self.clone();
diff --git a/zebra-state/src/service/non_finalized_state/chain.rs b/zebra-state/src/service/non_finalized_state/chain.rs
index e25b1fd17..12ee05287 100644
--- a/zebra-state/src/service/non_finalized_state/chain.rs
+++ b/zebra-state/src/service/non_finalized_state/chain.rs
@@ -482,6 +482,17 @@ impl Chain {
         )
     }
 
+    /// Returns the non-finalized tip block height, hash, and total pool value balances.
+    pub fn non_finalized_tip_with_value_balance(
+        &self,
+    ) -> (Height, block::Hash, ValueBalance<NonNegative>) {
+        (
+            self.non_finalized_tip_height(),
+            self.non_finalized_tip_hash(),
+            self.chain_value_pools,
+        )
+    }
+
     /// Returns the Sprout note commitment tree of the tip of this [`Chain`],
     /// including all finalized notes, and the non-finalized notes in this chain.
     ///
diff --git a/zebra-state/src/service/read.rs b/zebra-state/src/service/read.rs
index b8d022392..0188ca1bf 100644
--- a/zebra-state/src/service/read.rs
+++ b/zebra-state/src/service/read.rs
@@ -36,7 +36,7 @@ pub use block::{
 pub use find::{
     best_tip, block_locator, depth, finalized_state_contains_block_hash, find_chain_hashes,
     find_chain_headers, hash_by_height, height_by_hash, next_median_time_past,
-    non_finalized_state_contains_block_hash, tip, tip_height,
+    non_finalized_state_contains_block_hash, tip, tip_height, tip_with_value_balance,
 };
 pub use tree::{orchard_subtrees, orchard_tree, sapling_subtrees, sapling_tree};
 
diff --git a/zebra-state/src/service/read/find.rs b/zebra-state/src/service/read/find.rs
index 187510c9b..e9d557dbf 100644
--- a/zebra-state/src/service/read/find.rs
+++ b/zebra-state/src/service/read/find.rs
@@ -19,8 +19,10 @@ use std::{
 
 use chrono::{DateTime, Utc};
 use zebra_chain::{
+    amount::NonNegative,
     block::{self, Block, Height},
     serialization::DateTime32,
+    value_balance::ValueBalance,
 };
 
 use crate::{
@@ -82,6 +84,40 @@ where
     tip(chain, db).map(|(_height, hash)| hash)
 }
 
+/// Returns the tip of `chain` with its [`ValueBalance`].
+/// If there is no chain, returns the tip of `db`.
+pub fn tip_with_value_balance<C>(
+    chain: Option<C>,
+    db: &ZebraDb,
+) -> Result<Option<(Height, block::Hash, ValueBalance<NonNegative>)>, BoxError>
+where
+    C: AsRef<Chain>,
+{
+    match chain.map(|chain| chain.as_ref().non_finalized_tip_with_value_balance()) {
+        tip_with_value_balance @ Some(_) => Ok(tip_with_value_balance),
+        None => {
+            // Retry the finalized state query if it was interrupted by a finalizing block.
+            //
+            // TODO: refactor this into a generic retry(finalized_closure, process_and_check_closure) fn
+            for _ in 0..=FINALIZED_STATE_QUERY_RETRIES {
+                let tip @ Some((tip_height, tip_hash)) = db.tip() else {
+                    return Ok(None);
+                };
+
+                let value_balance = db.finalized_value_pool();
+
+                if tip == db.tip() {
+                    return Ok(Some((tip_height, tip_hash, value_balance)));
+                }
+            }
+
+            Err("Zebra is committing too many blocks to the state, \
+                    wait until it syncs to the chain tip"
+                .into())
+        }
+    }
+}
+
 /// Return the depth of block `hash` from the chain tip.
 /// Searches `chain` for `hash`, then searches `db`.
 pub fn depth<C>(chain: Option<C>, db: &ZebraDb, hash: block::Hash) -> Option<u32>


Test patch:
diff --git a/zebra-rpc/src/methods/tests/prop.rs b/zebra-rpc/src/methods/tests/prop.rs
index 38c3c6b08..c2a9c70a3 100644
--- a/zebra-rpc/src/methods/tests/prop.rs
+++ b/zebra-rpc/src/methods/tests/prop.rs
@@ -1,6 +1,6 @@
 //! Randomised property tests for RPC methods.
 
-use std::collections::HashSet;
+use std::{collections::HashSet, sync::Arc};
 
 use futures::{join, FutureExt, TryFutureExt};
 use hex::ToHex;
@@ -11,7 +11,7 @@ use tower::buffer::Buffer;
 
 use zebra_chain::{
     amount::{Amount, NonNegative},
-    block::{Block, Height},
+    block::{self, Block, Height},
     chain_tip::{mock::MockChainTip, NoChainTip},
     parameters::{
         Network::{self, *},
@@ -20,6 +20,7 @@ use zebra_chain::{
     serialization::{ZcashDeserialize, ZcashSerialize},
     transaction::{self, Transaction, UnminedTx, VerifiedUnminedTx},
     transparent,
+    value_balance::ValueBalance,
 };
 use zebra_node_services::mempool;
 use zebra_state::BoxError;
@@ -553,19 +554,34 @@ proptest! {
             NoChainTip,
         );
 
-        let response = rpc.get_blockchain_info();
-        prop_assert_eq!(
-            &response.err().unwrap().message,
-            "No Chain tip available yet"
-        );
-
-        // The queue task should continue without errors or panics
-        let rpc_tx_queue_task_result = rpc_tx_queue_task_handle.now_or_never();
-        prop_assert!(rpc_tx_queue_task_result.is_none());
 
         runtime.block_on(async move {
+            let response_fut = rpc.get_blockchain_info();
+            let mock_state_handler = {
+                let mut state = state.clone();
+                async move {
+                    state
+                        .expect_request(zebra_state::ReadRequest::TipPoolValues)
+                        .await
+                        .expect("getblockchaininfo should call mock state service with correct request")
+                        .respond(Err(BoxError::from("no chain tip available yet")));
+                }
+            };
+
+            let (response, _) = tokio::join!(response_fut, mock_state_handler);
+
+            prop_assert_eq!(
+                &response.err().unwrap().message,
+                "no chain tip available yet"
+            );
+
+            // The queue task should continue without errors or panics
+            let rpc_tx_queue_task_result = rpc_tx_queue_task_handle.now_or_never();
+            prop_assert!(rpc_tx_queue_task_result.is_none());
+
             mempool.expect_no_requests().await?;
             state.expect_no_requests().await?;
+
             Ok::<_, TestCaseError>(())
         })?;
     }
@@ -581,17 +597,11 @@ proptest! {
         let mut mempool = MockService::build().for_prop_tests();
         let mut state: MockService<_, _, _, BoxError> = MockService::build().for_prop_tests();
 
-        // get block data
+        // get arbitrary chain tip data
         let block_height = block.coinbase_height().unwrap();
         let block_hash = block.hash();
         let block_time = block.header.time;
 
-        // create a mocked `ChainTip`
-        let (chain_tip, mock_chain_tip_sender) = MockChainTip::new();
-        mock_chain_tip_sender.send_best_tip_height(block_height);
-        mock_chain_tip_sender.send_best_tip_hash(block_hash);
-        mock_chain_tip_sender.send_best_tip_block_time(block_time);
-
         // Start RPC with the mocked `ChainTip`
         let (rpc, rpc_tx_queue_task_handle) = RpcImpl::new(
             "RPC test",
@@ -601,50 +611,82 @@ proptest! {
             true,
             mempool.clone(),
             Buffer::new(state.clone(), 1),
-            chain_tip,
+            NoChainTip,
         );
-        let response = rpc.get_blockchain_info();
-
-        // Check response
-        match response {
-            Ok(info) => {
-                prop_assert_eq!(info.chain, network.bip70_network_name());
-                prop_assert_eq!(info.blocks, block_height);
-                prop_assert_eq!(info.best_block_hash, block_hash);
-                prop_assert!(info.estimated_height < Height::MAX);
-
-                prop_assert_eq!(
-                    info.consensus.chain_tip.0,
-                    NetworkUpgrade::current(&network, block_height)
-                        .branch_id()
-                        .unwrap()
-                );
-                prop_assert_eq!(
-                    info.consensus.next_block.0,
-                    NetworkUpgrade::current(&network, (block_height + 1).unwrap())
-                        .branch_id()
-                        .unwrap()
-                );
-
-                for u in info.upgrades {
-                    let mut status = NetworkUpgradeStatus::Active;
-                    if block_height < u.1.activation_height {
-                        status = NetworkUpgradeStatus::Pending;
+
+        // check no requests were made during this test
+        runtime.block_on(async move {
+            let response_fut = rpc.get_blockchain_info();
+            let mock_state_handler = {
+                let mut state = state.clone();
+                async move {
+                    state
+                        .expect_request(zebra_state::ReadRequest::TipPoolValues)
+                        .await
+                        .expect("getblockchaininfo should call mock state service with correct request")
+                        .respond(zebra_state::ReadResponse::TipPoolValues {
+                            tip_height: block_height,
+                            tip_hash: block_hash,
+                            value_balance: ValueBalance::default(),
+                        });
+
+                    state
+                        .expect_request(zebra_state::ReadRequest::BlockHeader(block_hash.into()))
+                        .await
+                        .expect("getblockchaininfo should call mock state service with correct request")
+                        .respond(zebra_state::ReadResponse::BlockHeader(Some(Arc::new(block::Header {
+                            time: block_time,
+                            version: Default::default(),
+                            previous_block_hash: Default::default(),
+                            merkle_root: Default::default(),
+                            commitment_bytes: Default::default(),
+                            difficulty_threshold: Default::default(),
+                            nonce: Default::default(),
+                            solution: Default::default()
+                        }))));
+                }
+            };
+
+            let (response, _) = tokio::join!(response_fut, mock_state_handler);
+
+            // Check response
+            match response {
+                Ok(info) => {
+                    prop_assert_eq!(info.chain, network.bip70_network_name());
+                    prop_assert_eq!(info.blocks, block_height);
+                    prop_assert_eq!(info.best_block_hash, block_hash);
+                    prop_assert!(info.estimated_height < Height::MAX);
+
+                    prop_assert_eq!(
+                        info.consensus.chain_tip.0,
+                        NetworkUpgrade::current(&network, block_height)
+                            .branch_id()
+                            .unwrap()
+                    );
+                    prop_assert_eq!(
+                        info.consensus.next_block.0,
+                        NetworkUpgrade::current(&network, (block_height + 1).unwrap())
+                            .branch_id()
+                            .unwrap()
+                    );
+
+                    for u in info.upgrades {
+                        let mut status = NetworkUpgradeStatus::Active;
+                        if block_height < u.1.activation_height {
+                            status = NetworkUpgradeStatus::Pending;
+                        }
+                        prop_assert_eq!(u.1.status, status);
                     }
-                    prop_assert_eq!(u.1.status, status);
                 }
-            }
-            Err(_) => {
-                unreachable!("Test should never error with the data we are feeding it")
-            }
-        };
+                Err(_) => {
+                    unreachable!("Test should never error with the data we are feeding it")
+                }
+            };
 
-        // The queue task should continue without errors or panics
-        let rpc_tx_queue_task_result = rpc_tx_queue_task_handle.now_or_never();
-        prop_assert!(rpc_tx_queue_task_result.is_none());
+            // The queue task should continue without errors or panics
+            let rpc_tx_queue_task_result = rpc_tx_queue_task_handle.now_or_never();
+            prop_assert!(rpc_tx_queue_task_result.is_none());
 
-        // check no requests were made during this test
-        runtime.block_on(async move {
             mempool.expect_no_requests().await?;
             state.expect_no_requests().await?;
 
diff --git a/zebra-rpc/src/methods/tests/snapshot.rs b/zebra-rpc/src/methods/tests/snapshot.rs
index 55c4fee86..f4d780408 100644
--- a/zebra-rpc/src/methods/tests/snapshot.rs
+++ b/zebra-rpc/src/methods/tests/snapshot.rs
@@ -2,7 +2,7 @@
 //!
 //! To update these snapshots, run:
 //! ```sh
-//! cargo insta test --review -p zebra-rpc --lib -- test_rpc_response_data
+//! cargo insta test --review --release -p zebra-rpc --lib -- test_rpc_response_data
 //! ```
 
 use std::collections::BTreeMap;
@@ -210,6 +210,7 @@ async fn test_rpc_response_data_for_network(network: &Network) {
     if network.is_a_test_network() && !network.is_default_testnet() {
         let get_blockchain_info = rpc
             .get_blockchain_info()
+            .await
             .expect("We should have a GetBlockChainInfo struct");
         snapshot_rpc_getblockchaininfo("_future_nu6_height", get_blockchain_info, &settings);
 
@@ -223,6 +224,7 @@ async fn test_rpc_response_data_for_network(network: &Network) {
     // `getblockchaininfo`
     let get_blockchain_info = rpc
         .get_blockchain_info()
+        .await
         .expect("We should have a GetBlockChainInfo struct");
     snapshot_rpc_getblockchaininfo("", get_blockchain_info, &settings);
 
diff --git a/zebra-rpc/src/methods/tests/snapshots/get_blockchain_info@mainnet_10.snap b/zebra-rpc/src/methods/tests/snapshots/get_blockchain_info@mainnet_10.snap
index 595a29bca..64663b861 100644
--- a/zebra-rpc/src/methods/tests/snapshots/get_blockchain_info@mainnet_10.snap
+++ b/zebra-rpc/src/methods/tests/snapshots/get_blockchain_info@mainnet_10.snap
@@ -7,6 +7,33 @@ expression: info
   "blocks": 10,
   "bestblockhash": "00074c46a4aa8172df8ae2ad1848a2e084e1b6989b7d9e6132adc938bf835b36",
   "estimatedheight": "[Height]",
+  "valuePools": [
+    {
+      "id": "transparent",
+      "chainValue": 0.034375,
+      "chainValueZat": 3437500
+    },
+    {
+      "id": "sprout",
+      "chainValue": 0.0,
+      "chainValueZat": 0
+    },
+    {
+      "id": "sapling",
+      "chainValue": 0.0,
+      "chainValueZat": 0
+    },
+    {
+      "id": "orchard",
+      "chainValue": 0.0,
+      "chainValueZat": 0
+    },
+    {
+      "id": "deferred",
+      "chainValue": 0.0,
+      "chainValueZat": 0
+    }
+  ],
   "upgrades": {
     "5ba81b19": {
       "name": "Overwinter",
diff --git a/zebra-rpc/src/methods/tests/snapshots/get_blockchain_info@testnet_10.snap b/zebra-rpc/src/methods/tests/snapshots/get_blockchain_info@testnet_10.snap
index 10a6f7f0b..d5c7d4b53 100644
--- a/zebra-rpc/src/methods/tests/snapshots/get_blockchain_info@testnet_10.snap
+++ b/zebra-rpc/src/methods/tests/snapshots/get_blockchain_info@testnet_10.snap
@@ -7,6 +7,33 @@ expression: info
   "blocks": 10,
   "bestblockhash": "079f4c752729be63e6341ee9bce42fbbe37236aba22e3deb82405f3c2805c112",
   "estimatedheight": "[Height]",
+  "valuePools": [
+    {
+      "id": "transparent",
+      "chainValue": 0.034375,
+      "chainValueZat": 3437500
+    },
+    {
+      "id": "sprout",
+      "chainValue": 0.0,
+      "chainValueZat": 0
+    },
+    {
+      "id": "sapling",
+      "chainValue": 0.0,
+      "chainValueZat": 0
+    },
+    {
+      "id": "orchard",
+      "chainValue": 0.0,
+      "chainValueZat": 0
+    },
+    {
+      "id": "deferred",
+      "chainValue": 0.0,
+      "chainValueZat": 0
+    }
+  ],
   "upgrades": {
     "5ba81b19": {
       "name": "Overwinter",
diff --git a/zebra-rpc/src/methods/tests/snapshots/get_blockchain_info_future_nu6_height@nu6testnet_10.snap b/zebra-rpc/src/methods/tests/snapshots/get_blockchain_info_future_nu6_height@nu6testnet_10.snap
index d2250e7fe..393e53f5a 100644
--- a/zebra-rpc/src/methods/tests/snapshots/get_blockchain_info_future_nu6_height@nu6testnet_10.snap
+++ b/zebra-rpc/src/methods/tests/snapshots/get_blockchain_info_future_nu6_height@nu6testnet_10.snap
@@ -7,6 +7,33 @@ expression: info
   "blocks": 10,
   "bestblockhash": "079f4c752729be63e6341ee9bce42fbbe37236aba22e3deb82405f3c2805c112",
   "estimatedheight": "[Height]",
+  "valuePools": [
+    {
+      "id": "transparent",
+      "chainValue": 0.034375,
+      "chainValueZat": 3437500
+    },
+    {
+      "id": "sprout",
+      "chainValue": 0.0,
+      "chainValueZat": 0
+    },
+    {
+      "id": "sapling",
+      "chainValue": 0.0,
+      "chainValueZat": 0
+    },
+    {
+      "id": "orchard",
+      "chainValue": 0.0,
+      "chainValueZat": 0
+    },
+    {
+      "id": "deferred",
+      "chainValue": 0.0,
+      "chainValueZat": 0
+    }
+  ],
   "upgrades": {
     "5ba81b19": {
       "name": "Overwinter",