diff --git a/Cargo.lock b/Cargo.lock index 689504b3d..1622430b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -736,6 +736,19 @@ dependencies = [ "constant_time_eq", ] +[[package]] +name = "blake3" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", +] + [[package]] name = "blanket" version = "0.3.0" @@ -8390,6 +8403,7 @@ name = "zaino-fetch" version = "0.1.2" dependencies = [ "base64", + "blake3", "byteorder", "derive_more", "hex", @@ -8406,6 +8420,7 @@ dependencies = [ "tonic 0.12.3", "tracing", "url", + "zaino-common", "zaino-proto", "zaino-testvectors", "zebra-chain", diff --git a/docs/json_rpc/gettxoutsetinfo.md b/docs/json_rpc/gettxoutsetinfo.md new file mode 100644 index 000000000..564721591 --- /dev/null +++ b/docs/json_rpc/gettxoutsetinfo.md @@ -0,0 +1,3 @@ +# `gettxoutsetinfo` + +See [Zaino's Unspent Hash set](./gettxoutsetinfo/canonical_utxo_set_snapshot_hash.md) for more information on how the UTXO set hash is computed. diff --git a/docs/json_rpc/gettxoutsetinfo/canonical_utxo_set_snapshot_hash.md b/docs/json_rpc/gettxoutsetinfo/canonical_utxo_set_snapshot_hash.md new file mode 100644 index 000000000..d09f6930b --- /dev/null +++ b/docs/json_rpc/gettxoutsetinfo/canonical_utxo_set_snapshot_hash.md @@ -0,0 +1,168 @@ + Title: ZAINO-UTXOSET-01 Canonical UTXO Set Snapshot Hash (v1) + Owners: dorianvp + Za Wil + Status: Draft + Category: Lightclients + Created: 2025-10-16 + License: MIT + +## Terminology + +- The key words **MUST**, **MUST NOT**, **SHOULD**, and **MAY** are to be interpreted as described in BCP 14 [^BCP14] when, and only when, they appear in all capitals.. +- Integers are encoded **little-endian** unless otherwise stated. +- “CompactSize” refers to the [Bitcoin Specified](https://en.bitcoin.it/wiki/Protocol_documentation#Variable_length_integer) [Zcash Implementation](https://docs.rs/zcash_encoding/0.3.0/zcash_encoding/struct.CompactSize.html) of variable-length integer format. +- `BLAKE3` denotes the 32-byte output of the BLAKE3 hash function. +- This specification defines **version 1** (“V1”) of the ZAINO UTXO snapshot. + +## Abstract + +This document specifies a deterministic, versioned procedure to compute a 32-byte hash of a node’s UTXO set at a specified best block. The snapshot uses a canonical ordering and serialization and is hashed under a domain tag. + +Among other uses, the snapshot hash can be used to: + +- Verify that two nodes at the same best block have the same UTXO set across implementations and versions. +- Pin failing test fixtures to a snapshot hash to reproduce issues. +- Log periodic hashes to show continuity of state over time. + +The hash is _not_ input to consensus validation. + +## Motivation + +Different nodes (e.g., `zcashd`, Zebra, indexers) may expose distinct internals or storage layouts. Operators often need a cheap way to verify “we’re looking at the same unspent set” without transporting the entire set. A canonical, versioned snapshot hash solves this. + +## Domain Separation + +Implementations **MUST** domain-separate the hash with the ASCII header: + +``` +"ZAINO-UTXOSET-V1\0" +``` + +Any change to the encoding rules or semantics **MUST** bump the domain string (e.g., `…-V2\0`) and is out of scope of this document. + +## Inputs + +To compute the snapshot hash, the implementation needs: + +- `genesis_block_hash`: 32-byte hash that uniquely identifies the chain. +- `best_height`: the height of the best block at the time of the snapshot (unsigned 32-bit). +- `best_block`: the 32-byte block hash of the best chain tip, in the node’s _canonical internal byte order_. +- `UTXO set`: a finite multimap keyed by outpoints `(txid, vout)` to outputs `(value_zat, scriptPubKey)`, where: + + - `txid` is a 32-byte transaction hash (internal byte order). + - `vout` is a 32-bit output index (0-based). + - `value_zat` is a non-negative amount in zatoshis, range-checked to the node’s monetary bounds (e.g., `0 ≤ value_zat ≤ MAX_MONEY`). + - `scriptPubKey` is a byte string. + +Implementations **MUST** reject negative values or out-of-range amounts prior to hashing. + +## Canonical Ordering + +The snapshot **MUST** be ordered as follows, independent of the node’s in-memory layout: + +1. Sort by `txid` ascending, comparing the raw 32-byte values as unsigned bytes. +2. For equal `txid`s, sort by `vout` ascending (unsigned 32-bit). + +This ordering **MUST** be used for serialization. + +## Serialization + +The byte stream fed to the hash is the concatenation of a **header** and **entries**: + +### Header + +- ASCII bytes: `"ZAINO-UTXOSET-V1\0"` +- `genesis_block_hash`: 32 raw bytes +- `best_height` as `u32` little-endian. +- `best_block` as 32 raw bytes. +- `count_txouts` as `u64` little-endian, where `count_txouts` is the total number of serialized entries below. + +### Entries (one per outpoint in canonical order) + +For each `(txid, vout, value_zat, scriptPubKey)`: + +- `txid` as 32 raw bytes. +- `vout` as `u32` little-endian. +- `value_zat` as `u64` little-endian. +- `script_len` as CompactSize (Bitcoin/Zcash varint) of `scriptPubKey.len()`. +- `scriptPubKey` raw bytes. + +**Note:** No per-transaction terminators or grouping markers are used. Instead, the format commits to _outputs_, not _transactions_. + +### CompactSize ([reference](https://en.bitcoin.it/wiki/Protocol_documentation#Variable_length_integer)) + +- If `n < 0xFD`: a single byte `n`. +- Else if `n ≤ 0xFFFF`: `0xFD` followed by `n` as `u16` little-endian. +- Else if `n ≤ 0xFFFF_FFFF`: `0xFE` followed by `n` as `u32` little-endian. +- Else: `0xFF` followed by `n` as `u64` little-endian. + +## Hash Function + +- The implementation **MUST** stream the bytes above into a BLAKE3 hasher. +- The 32-byte output of the hasher is the **snapshot hash**. + +## Pseudocode + +```text +function UtxoSnapshotHashV1(genesis_block_hash, best_height, best_block, utxos): + H ← blake3::Hasher() + + // Header + H.update("ZAINO-UTXOSET-V1\0") + H.update(genesis_block_hash) + H.update(le_u32(best_height)) + H.update(best_block) // 32 raw bytes, node’s canonical order + count ← number_of_outputs(utxos) + H.update(le_u64(count)) + + // Entries in canonical order + for (txid, vout, value, script) in sort_by_txid_then_vout(utxos): + assert 0 ≤ value ≤ MAX_MONEY + H.update(txid) // 32 raw bytes + H.update(le_u32(vout)) + H.update(le_u64(value)) // zatoshis + H.update(CompactSize(script.len)) + H.update(script) + + return H.finalize() // 32-byte BLAKE3 digest +``` + +## Error Handling + +- If any `value_zat` is negative or exceeds `MAX_MONEY`, the snapshot procedure **MUST** fail and **MUST NOT** produce a hash. +- If the UTXO set changes during iteration (non-atomic read), the implementation **SHOULD** retry using a stable view (e.g., read lock or height-pinned snapshot). + +## Security and Interop Considerations + +- This hash is **not a consensus commitment** and **MUST NOT** be used to validate blocks or transactions. +- The domain string identifies the algorithm/format version. Any change **MUST** use a new tag. +- The snapshot **MUST** bind to a specific chain and tip by including best_block (32 bytes, consensus byte order) and + **SHOULD** include `best_height`. Implementations **SHOULD** include `genesis_block_hash` (32 bytes) as the chain identifier. +- The serialization **MUST** be injective. Duplicates or out-of-range values **MUST** cause failure. +- Equal hashes indicate equal inputs under this specification. They do not imply authenticity, provenance, or liveness. + +## Rationale + +- **BLAKE3** is chosen for speed and strong modern security. SHA-256 would also work but is slower in large sets. The domain string ensures local uniqueness regardless of the hash function family. +- Committing to _outputs_ rather than _transactions_ simplifies implementations that don’t have transaction-grouped storage. +- CompactSize matches existing Bitcoin/Zcash encoding and avoids ambiguity. + +## Versioning + +- Any breaking change to the byte stream, input semantics, or ordering **MUST** bump the domain tag to `ZAINO-UTXOSET-V2\0` (or higher). +- Implementations **SHOULD** publish the version alongside the hash in logs and APIs. + +## Test Guidance + +Implementations **SHOULD** include tests covering: + +1. **Determinism:** Shuffle input, and the hash remains constant. +2. **Sensitivity:** Flip one bit in `value_zat` or `scriptPubKey`, and the hash changes. +3. **Metadata:** Change `genesis_block_hash` or `best_block`, and the hash changes. +4. **Empty Set:** With `count_txouts = 0`, the hash is well-defined. +5. **Large Scripts:** Scripts with CompactSize boundaries (252, 253, 2^16, 2^32). +6. **Ordering:** Two entries with same `txid` different `vout` are ordered by `vout`. + +## References + +[^BCP14]: [Information on BCP 14 — "RFC 2119"](https://www.rfc-editor.org/info/bcp14) diff --git a/integration-tests/tests/fetch_service.rs b/integration-tests/tests/fetch_service.rs index 7a15d1c76..ea9f13194 100644 --- a/integration-tests/tests/fetch_service.rs +++ b/integration-tests/tests/fetch_service.rs @@ -1,4 +1,6 @@ -//! These tests compare the output of `FetchService` with the output of `JsonRpcConnector`. +//! These tests compare the output of `FetchService` with the output of [`JsonRpSeeConnector`]. +//! +//! Note that they both rely on the [`JsonRpSeeConnector`] to get the data. use futures::StreamExt as _; use zaino_common::network::{ActivationHeights, ZEBRAD_DEFAULT_ACTIVATION_HEIGHTS}; @@ -497,6 +499,51 @@ async fn fetch_service_get_address_tx_ids(validator: &ValidatorKind) { test_manager.close().await; } +async fn fetch_service_get_txout_set_info() { + let (mut test_manager, _fetch_service, fetch_service_subscriber) = + create_test_manager_and_fetch_service(&ValidatorKind::Zcashd, None, true, true, true).await; + + let mut clients = test_manager + .clients + .take() + .expect("Clients are not initialized"); + clients.faucet.sync_and_await().await.unwrap(); + + let recipient_ua = clients.get_recipient_address("unified").await; + let _tx = zaino_testutils::from_inputs::quick_send( + &mut clients.faucet, + vec![(&recipient_ua, 250_000, None)], + ) + .await + .unwrap(); + + test_manager.local_net.generate_blocks(1).await.unwrap(); + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + let txout_set_info = fetch_service_subscriber.get_txout_set_info().await.unwrap(); + + let jsonrpc_client = JsonRpSeeConnector::new_with_basic_auth( + test_node_and_return_url( + test_manager.full_node_rpc_listen_address, + None, + Some("xxxxxx".to_string()), + Some("xxxxxx".to_string()), + ) + .await + .unwrap(), + "xxxxxx".to_string(), + "xxxxxx".to_string(), + ) + .unwrap(); + let json_rpc_txout_set_info = jsonrpc_client.get_txout_set_info().await.unwrap(); + dbg!(&json_rpc_txout_set_info); + dbg!(&txout_set_info); + + assert_eq!(txout_set_info, json_rpc_txout_set_info); + + test_manager.close().await; +} + async fn fetch_service_get_address_utxos(validator: &ValidatorKind) { let (mut test_manager, _fetch_service, fetch_service_subscriber) = create_test_manager_and_fetch_service(validator, None, true, true, true).await; @@ -1542,6 +1589,11 @@ mod zcashd { fetch_service_get_address_tx_ids(&ValidatorKind::Zcashd).await; } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + pub(crate) async fn txout_set_info() { + fetch_service_get_txout_set_info().await; + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] pub(crate) async fn address_utxos() { fetch_service_get_address_utxos(&ValidatorKind::Zcashd).await; diff --git a/integration-tests/tests/json_server.rs b/integration-tests/tests/json_server.rs index 737d3003f..4d3f3007d 100644 --- a/integration-tests/tests/json_server.rs +++ b/integration-tests/tests/json_server.rs @@ -1,4 +1,4 @@ -//! Tests that compare the output of both `zcashd` and `zainod` through `FetchService`. +//! Tests that compare the output of both `zcashd` and `zainod` through [`FetchService`]. use zaino_common::network::{ActivationHeights, ZEBRAD_DEFAULT_ACTIVATION_HEIGHTS}; use zaino_common::{DatabaseConfig, ServiceConfig, StorageConfig}; @@ -715,6 +715,50 @@ mod zcashd { test_manager.close().await; } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn get_txout_set_info() { + let ( + mut test_manager, + _zcashd_service, + zcashd_subscriber, + _zaino_service, + zaino_subscriber, + ) = create_test_manager_and_fetch_services(true).await; + + let mut clients = test_manager + .clients + .take() + .expect("Clients are not initialized"); + + test_manager.local_net.generate_blocks(1).await.unwrap(); + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + clients.faucet.sync_and_await().await.unwrap(); + + let recipient_ua = &clients.get_recipient_address("unified").await; + let recipient_taddr = &clients.get_recipient_address("transparent").await; + from_inputs::quick_send(&mut clients.faucet, vec![(recipient_taddr, 250_000, None)]) + .await + .unwrap(); + from_inputs::quick_send(&mut clients.faucet, vec![(recipient_ua, 250_000, None)]) + .await + .unwrap(); + + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + let zcashd_subscriber_txout_set_info = + zcashd_subscriber.get_txout_set_info().await.unwrap(); + let zaino_subscriber_txout_set_info = + zaino_subscriber.get_txout_set_info().await.unwrap(); + + assert_eq!( + zcashd_subscriber_txout_set_info, + zaino_subscriber_txout_set_info + ); + + test_manager.close().await; + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_mining_info() { let ( @@ -725,6 +769,27 @@ mod zcashd { zaino_subscriber, ) = create_test_manager_and_fetch_services(false).await; + let mut clients = test_manager + .clients + .take() + .expect("Clients are not initialized"); + + test_manager.local_net.generate_blocks(1).await.unwrap(); + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + clients.faucet.sync_and_await().await.unwrap(); + + let recipient_ua = &clients.get_recipient_address("unified").await; + let recipient_taddr = &clients.get_recipient_address("transparent").await; + from_inputs::quick_send(&mut clients.faucet, vec![(recipient_taddr, 250_000, None)]) + .await + .unwrap(); + from_inputs::quick_send(&mut clients.faucet, vec![(recipient_ua, 250_000, None)]) + .await + .unwrap(); + + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + const BLOCK_LIMIT: i32 = 10; for _ in 0..BLOCK_LIMIT { diff --git a/integration-tests/tests/state_service.rs b/integration-tests/tests/state_service.rs index 006e9234f..20834c1a7 100644 --- a/integration-tests/tests/state_service.rs +++ b/integration-tests/tests/state_service.rs @@ -1,3 +1,5 @@ +//! Tests that compare the output of the [`StateService`] with the output of [`FetchService`]. + use zaino_common::network::{ActivationHeights, ZEBRAD_DEFAULT_ACTIVATION_HEIGHTS}; use zaino_common::{DatabaseConfig, ServiceConfig, StorageConfig}; use zaino_state::BackendType; @@ -925,6 +927,59 @@ async fn state_service_get_address_tx_ids_testnet() { test_manager.close().await; } +async fn state_service_get_txout_set_info() { + let ( + mut test_manager, + _fetch_service, + _fetch_service_subscriber, + _state_service, + state_service_subscriber, + ) = create_test_manager_and_services(&ValidatorKind::Zebrad, None, true, true, None).await; + + let mut clients = test_manager + .clients + .take() + .expect("Clients are not initialized"); + test_manager.local_net.generate_blocks(1).await.unwrap(); + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + clients.faucet.sync_and_await().await.unwrap(); + + test_manager.local_net.generate_blocks(100).await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + clients.faucet.sync_and_await().await.unwrap(); + clients.faucet.quick_shield(AccountId::ZERO).await.unwrap(); + test_manager.local_net.generate_blocks(100).await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + clients.faucet.sync_and_await().await.unwrap(); + clients.faucet.quick_shield(AccountId::ZERO).await.unwrap(); + test_manager.local_net.generate_blocks(1).await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + clients.faucet.sync_and_await().await.unwrap(); + + let recipient_ua = clients.get_recipient_address("unified").await; + let recipient_taddr = clients.get_recipient_address("transparent").await; + from_inputs::quick_send(&mut clients.faucet, vec![(&recipient_taddr, 250_000, None)]) + .await + .unwrap(); + from_inputs::quick_send(&mut clients.faucet, vec![(&recipient_ua, 250_000, None)]) + .await + .unwrap(); + + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + test_manager.local_net.generate_blocks(2).await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + let txout_set_info_result = state_service_subscriber.get_txout_set_info().await; + + assert!(&txout_set_info_result.is_ok()); + + dbg!(&txout_set_info_result.unwrap()); + + test_manager.close().await; +} + async fn state_service_get_address_utxos(validator: &ValidatorKind) { let ( mut test_manager, @@ -1123,6 +1178,11 @@ mod zebrad { state_service_get_address_tx_ids_testnet().await; } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn txout_set_info() { + state_service_get_txout_set_info().await; + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn raw_transaction_regtest() { state_service_get_raw_transaction(&ValidatorKind::Zebrad).await; diff --git a/zaino-fetch/Cargo.toml b/zaino-fetch/Cargo.toml index 3185e0eb3..30086d55a 100644 --- a/zaino-fetch/Cargo.toml +++ b/zaino-fetch/Cargo.toml @@ -10,6 +10,7 @@ version = { workspace = true } [dependencies] zaino-proto = { workspace = true } +zaino-common = { workspace = true } # Zebra zebra-chain = { workspace = true } @@ -37,6 +38,7 @@ byteorder = { workspace = true } sha2 = { workspace = true } jsonrpsee-types = { workspace = true } derive_more = { workspace = true, features = ["from"] } +blake3 = "1.8.2" [dev-dependencies] zaino-testvectors = { workspace = true } diff --git a/zaino-fetch/src/jsonrpsee/connector.rs b/zaino-fetch/src/jsonrpsee/connector.rs index 43835f833..7b16cf10c 100644 --- a/zaino-fetch/src/jsonrpsee/connector.rs +++ b/zaino-fetch/src/jsonrpsee/connector.rs @@ -26,11 +26,12 @@ use crate::jsonrpsee::{ error::{JsonRpcError, TransportError}, response::{ block_subsidy::GetBlockSubsidy, mining_info::GetMiningInfoWire, peer_info::GetPeerInfo, - GetBalanceError, GetBalanceResponse, GetBlockCountResponse, GetBlockError, GetBlockHash, - GetBlockResponse, GetBlockchainInfoResponse, GetInfoResponse, GetMempoolInfoResponse, - GetSubtreesError, GetSubtreesResponse, GetTransactionResponse, GetTreestateError, - GetTreestateResponse, GetUtxosError, GetUtxosResponse, SendTransactionError, - SendTransactionResponse, TxidsError, TxidsResponse, + txout_set_info::GetTxOutSetInfo, GetBalanceError, GetBalanceResponse, + GetBlockCountResponse, GetBlockError, GetBlockHash, GetBlockResponse, + GetBlockchainInfoResponse, GetInfoResponse, GetMempoolInfoResponse, GetSubtreesError, + GetSubtreesResponse, GetTransactionResponse, GetTreestateError, GetTreestateResponse, + GetUtxosError, GetUtxosResponse, SendTransactionError, SendTransactionResponse, TxidsError, + TxidsResponse, }, }; @@ -701,6 +702,21 @@ impl JsonRpSeeConnector { self.send_request("getaddresstxids", vec![params]).await } + /// Returns statistics about the unspent transaction output set. + /// Note this call may take some time. + /// + /// zcashd reference: [`gettxoutsetinfo`](https://zcash.github.io/rpc/gettxoutsetinfo.html) + /// method: post + /// tags: blockchain + /// + /// # Notes + /// + /// Only `zcashd` supports this method. Zebra has no intention of supporting it. + pub async fn get_txout_set_info(&self) -> Result> { + self.send_request::<(), GetTxOutSetInfo>("gettxoutsetinfo", ()) + .await + } + /// Returns all unspent outputs for a list of addresses. /// /// zcashd reference: [`getaddressutxos`](https://zcash.github.io/rpc/getaddressutxos.html) diff --git a/zaino-fetch/src/jsonrpsee/response.rs b/zaino-fetch/src/jsonrpsee/response.rs index bc13abc97..4acc5f3d1 100644 --- a/zaino-fetch/src/jsonrpsee/response.rs +++ b/zaino-fetch/src/jsonrpsee/response.rs @@ -4,9 +4,10 @@ //! to prevent locking consumers into a zebra_rpc version pub mod block_subsidy; -mod common; +pub mod common; pub mod mining_info; pub mod peer_info; +pub mod txout_set_info; use std::{convert::Infallible, num::ParseIntError}; diff --git a/zaino-fetch/src/jsonrpsee/response/common.rs b/zaino-fetch/src/jsonrpsee/response/common.rs index 2fa0ab920..d258705cb 100644 --- a/zaino-fetch/src/jsonrpsee/response/common.rs +++ b/zaino-fetch/src/jsonrpsee/response/common.rs @@ -1,13 +1,17 @@ +//! Common types used across jsonrpsee responses + pub mod amount; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; +/// The identifier for a Zcash node. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct NodeId(pub i64); +/// The height of a Zcash block. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct BlockHeight(pub u32); @@ -18,6 +22,7 @@ impl From for BlockHeight { } } +/// The height of a Zcash block, or None if unknown. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct MaybeHeight(pub Option); @@ -33,7 +38,6 @@ impl Serialize for MaybeHeight { impl<'de> Deserialize<'de> for MaybeHeight { fn deserialize>(de: D) -> Result { // Accept either a number or null. - // Negative → None; non-negative → Some(height). let opt = Option::::deserialize(de)?; match opt { None => Ok(MaybeHeight(None)), @@ -46,10 +50,12 @@ impl<'de> Deserialize<'de> for MaybeHeight { } } +/// Unix timestamp. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct UnixTime(pub i64); impl UnixTime { + /// Converts to a [`SystemTime`]. pub fn as_system_time(self) -> SystemTime { if self.0 >= 0 { UNIX_EPOCH + Duration::from_secs(self.0 as u64) @@ -59,23 +65,28 @@ impl UnixTime { } } +/// Duration in seconds. #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[serde(transparent)] pub struct SecondsF64(pub f64); impl SecondsF64 { + /// Converts to a [`Duration`]. pub fn as_duration(self) -> Duration { Duration::from_secs_f64(self.0) } } +/// Protocol version. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct ProtocolVersion(pub i64); +/// A byte array. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct Bytes(pub u64); +/// Time offset in seconds. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct TimeOffsetSeconds(pub i64); diff --git a/zaino-fetch/src/jsonrpsee/response/common/amount.rs b/zaino-fetch/src/jsonrpsee/response/common/amount.rs index dea677751..6ba0a5802 100644 --- a/zaino-fetch/src/jsonrpsee/response/common/amount.rs +++ b/zaino-fetch/src/jsonrpsee/response/common/amount.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; +/// The number of Zatoshis per ZEC. pub const ZATS_PER_ZEC: u64 = 100_000_000; /// Represents an amount in Zatoshis. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] @@ -41,6 +42,7 @@ impl<'de> Deserialize<'de> for Zatoshis { pub struct ZecAmount(u64); impl ZecAmount { + /// Get the amount in zatoshis. pub fn as_zatoshis(self) -> u64 { self.0 } diff --git a/zaino-fetch/src/jsonrpsee/response/txout_set_info.rs b/zaino-fetch/src/jsonrpsee/response/txout_set_info.rs new file mode 100644 index 000000000..7076cbea1 --- /dev/null +++ b/zaino-fetch/src/jsonrpsee/response/txout_set_info.rs @@ -0,0 +1,796 @@ +//! Types associated with the `gettxoutsetinfo` RPC request. +//! +//! Although the current threat model assumes that `zaino` connects to a trusted validator, +//! the `gettxoutsetinfo` RPC performs some light validation. + +use std::convert::Infallible; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::jsonrpsee::{ + connector::ResponseToError, + response::common::{amount::ZecAmount, BlockHeight}, +}; + +/// Response to a `gettxoutsetinfo` RPC request. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum GetTxOutSetInfo { + /// Validated payload. + Known(TxOutSetInfo), + + /// Unrecognized shape. + Unknown(Value), +} + +/// Response to a `gettxoutsetinfo` RPC request. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TxOutSetInfo { + /// The current block height. + pub height: BlockHeight, + + /// The best block hash hex. + #[serde(with = "hex", rename = "bestblock")] + pub best_block: zebra_chain::block::Hash, + + /// The number of transactions. + pub transactions: u64, + + /// The number of output transactions. + #[serde(rename = "txouts")] + pub tx_outs: u64, + + /// The serialized size. + pub bytes_serialized: u64, + + /// The serialized hash. + pub hash_serialized: String, + + /// The total amount, in ZEC. + pub total_amount: ZecAmount, +} + +impl ResponseToError for GetTxOutSetInfo { + type RpcError = Infallible; +} + +/// This module provides helper functions and types for computing the canonical UTXO set hash. +pub mod utxo_set_hash { + use std::collections::BTreeMap; + + use zebra_chain::amount::MAX_MONEY; + + pub(crate) const DOMAIN_TAG: &[u8] = b"ZAINO-UTXOSET-V1\0"; + pub(crate) const NETWORK_TAG_LEN: u64 = b"mainnet".len() as u64; // Same as testnet and regtest + pub(crate) const NETWORK_TAG_NUL: &[u8] = b"\0"; + + /// A single UTXO snapshot item. + #[derive(Debug, Clone)] + pub struct SnapshotItem { + /// Raw txid bytes. Same order everywhere. + pub txid_raw: [u8; 32], + + /// vout. + pub index: u32, + + /// Zatoshis. + pub value_zat: u64, + + /// scriptPubKey. + pub script: Vec, + } + + /// Encode Zcash CompactSize varint. + pub(crate) fn write_compact_size(size: u64, h: &mut blake3::Hasher) { + if size < 0xFD { + h.update(&[size as u8]); + } else if size <= 0xFFFF { + h.update(&[0xFD, (size & 0xFF) as u8, ((size >> 8) & 0xFF) as u8]); + } else if size <= 0xFFFF_FFFF { + h.update(&[ + 0xFE, + (size & 0xFF) as u8, + ((size >> 8) & 0xFF) as u8, + ((size >> 16) & 0xFF) as u8, + ((size >> 24) & 0xFF) as u8, + ]); + } else { + h.update(&[ + 0xFF, + (size & 0xFF) as u8, + ((size >> 8) & 0xFF) as u8, + ((size >> 16) & 0xFF) as u8, + ((size >> 24) & 0xFF) as u8, + ((size >> 32) & 0xFF) as u8, + ((size >> 40) & 0xFF) as u8, + ((size >> 48) & 0xFF) as u8, + ((size >> 56) & 0xFF) as u8, + ]); + } + } + + /// Return the number of bytes needed to encode a CompactSize varint. + #[inline] + pub(crate) fn compact_size_len(n: u64) -> u64 { + if n < 0xFD { + 1 + } else if n <= 0xFFFF { + 3 + } else if n <= 0xFFFF_FFFF { + 5 + } else { + 9 + } + } + + /// Error type for UTXO set hash computation. + #[derive(Debug, thiserror::Error)] + pub enum UtxoSetError { + /// Duplicate outpoint in the UTXO set. + #[error("duplicate outpoint")] + DuplicateOutpoint, + } + + /// Compute canonical snapshot hash. See ZAINO-UTXOSET-01 for details. + /// + /// - `genesis_block_hash`: raw 32-byte genesis block hash. + /// - `best_height`: current block height. + /// - `best_block_hash`: raw 32-byte block hash. + /// - `items`: anything that can be iterated, we'll sort it into BTreeMap to canonicalize order. + pub fn utxo_set_hash_v1( + genesis_block_hash: [u8; 32], + best_height: u32, + best_block_hash: [u8; 32], + items: I, + ) -> Result + where + I: IntoIterator, + { + // Group by txid, then by index to detect duplicates. + let mut ordered: BTreeMap<[u8; 32], BTreeMap> = BTreeMap::new(); + for it in items { + let per_tx = ordered.entry(it.txid_raw).or_default(); + + if per_tx.contains_key(&it.index) { + return Err(UtxoSetError::DuplicateOutpoint); + } + per_tx.insert(it.index, it); + } + + let mut h = blake3::Hasher::new(); + + // Header, with domain separation and metadata + h.update(DOMAIN_TAG); + h.update(&genesis_block_hash); + h.update(&best_height.to_le_bytes()); + h.update(&best_block_hash); + let total_outputs: u64 = ordered.values().map(|v| v.len() as u64).sum(); + h.update(&total_outputs.to_le_bytes()); + + // Entries (txid asc, vout asc) + for (txid, outs) in ordered { + for (index, o) in outs { + // Range check value + assert!( + o.value_zat <= MAX_MONEY.try_into().unwrap(), + "value_zat out of range" + ); + h.update(&txid); + h.update(&index.to_le_bytes()); + h.update(&o.value_zat.to_le_bytes()); + let slen = u64::try_from(o.script.len()).expect("script too long"); + write_compact_size(slen, &mut h); + h.update(&o.script); + } + } + + Ok(h.finalize()) + } + + /// Compute the serialized size (in bytes) of the UTXO set snapshot using the same + /// deterministic V1 format as `utxo_set_hash_v1`. + /// + /// This MUST stay in lockstep with the hashing/serialization spec: + /// - Header: "ZAINO-UTXOSET-V1\0" || `network` || `NUL` || `height_le` || `best_block` || `total_outputs_le` + /// - Entries (sorted by `txid` asc, `index` asc): `txid` || `index_le` || `value_le` || `CompactSize(script_len)` || `script` + pub fn utxo_set_serialized_size_v1(items: I) -> u64 + where + I: IntoIterator, + { + // Header size, independent of contents + let mut total = 0u64; + total += DOMAIN_TAG.len() as u64; + total += NETWORK_TAG_LEN; + total += NETWORK_TAG_NUL.len() as u64; + total += std::mem::size_of::() as u64; // best_height + total += std::mem::size_of::<[u8; 32]>() as u64; // best_block_hash + total += std::mem::size_of::() as u64; // total_outputs + + // Canonicalize order to match hashing (not strictly necessary for size, but keeps invariants). + use std::collections::BTreeMap; + let mut ordered: BTreeMap<[u8; 32], Vec> = BTreeMap::new(); + + for item in items { + ordered.entry(item.txid_raw).or_default().push(item); + } + for v in ordered.values_mut() { + v.sort_by_key(|x| x.index); + } + + for outs in ordered.into_values() { + for o in outs { + total += 32; // txid + total += 4; // index + total += 8; // value_zat + let slen = o.script.len() as u64; + total += compact_size_len(slen); // varint length + total += slen; // script bytes + } + } + + total + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::jsonrpsee::response::txout_set_info::{GetTxOutSetInfo, TxOutSetInfo}; + + const GENESIS_BLOCK_HASH: &str = + "00040fe8ec8471911baa1db1266ea15dd06b4a8a5c453883c000b031973dce08"; + + #[test] + fn txoutsetinfo_parses_known_with_numeric_amount() { + // `zcashd` payload, with the amount as a number + let j = json!({ + "height": 123, + "bestblock": "029f11d80ef9765602235e1bc9727e3eb6ba20839319f761fee920d63401e327", + "transactions": 42, + "txouts": 77, + "bytes_serialized": 999, + "hash_serialized": "c26d00...718f", + "total_amount": 3.5 + }); + + let parsed: GetTxOutSetInfo = serde_json::from_value(j).unwrap(); + match parsed { + GetTxOutSetInfo::Known(k) => { + assert_eq!(k.height.0, 123); + assert_eq!(k.transactions, 42); + assert_eq!(k.tx_outs, 77); + assert_eq!(k.bytes_serialized, 999); + assert_eq!(k.hash_serialized, "c26d00...718f"); + assert_eq!(u64::from(k.total_amount), 350_000_000); + + // BlockHash round-trip formatting is stable + let hex = k.best_block.to_string(); + assert_eq!(hex.len(), 64); + let back: zebra_chain::block::Hash = hex.parse().unwrap(); + assert_eq!(k.best_block, back); + } + other => panic!("expected Known, got: {:?}", other), + } + } + + #[test] + fn txoutsetinfo_parses_known_with_string_amount() { + // Amount as a string is accepted + let known_json = json!({ + "height": 0, + "bestblock": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "transactions": 0, + "txouts": 0, + "bytes_serialized": 0, + "hash_serialized": "deadbeef", + "total_amount": "0.00000001" + }); + + match serde_json::from_value::(known_json) { + Ok(k) => { + assert_eq!(k.height.0, 0); + assert_eq!(u64::from(k.total_amount), 1); + } + Err(e) => { + panic!("expected Ok, got: {}", e); + } + } + } + + /// Missing 'bestblock'. Should deserialize to [`GetTxOutSetInfo::Unknown`]. + #[test] + fn txoutsetinfo_falls_back_to_unknown_when_required_fields_missing() { + let j = json!({ + "height": 1, + "transactions": 0, + "txouts": 0, + "bytes_serialized": 0, + "hash_serialized": "x", + "total_amount": 0 + }); + + let parsed: GetTxOutSetInfo = serde_json::from_value(j).unwrap(); + match parsed { + GetTxOutSetInfo::Unknown(v) => { + assert!(v.get("bestblock").is_none()); + } + other => panic!("expected Unknown, got: {:?}", other), + } + } + + /// UTXO Set Hash tests + /// + /// For more information, see `ZAINO-UTXOSET-01`. + mod utxoset_hash { + use crate::jsonrpsee::response::txout_set_info::tests::GENESIS_BLOCK_HASH; + + use super::super::utxo_set_hash::*; + use blake3; + + fn txid(fill: u8) -> [u8; 32] { + [fill; 32] + } + + fn script(len: usize, byte: u8) -> Vec { + vec![byte; len] + } + + fn item(fill: u8, index: u32, value_zat: u64, script_len: usize) -> SnapshotItem { + SnapshotItem { + txid_raw: txid(fill), + index, + value_zat, + script: script(script_len, 0xAA), + } + } + + #[test] + fn compact_size_encoding_edges() { + // Verify that the CompactSize encoder emits the exact byte sequences + // for boundaries: <0xFD, 0xFD..=0xFFFF, 0x10000..=0xFFFF_FFFF, >= 2^32. + fn expected_bytes(n: u64) -> Vec { + if n < 0xFD { + vec![n as u8] + } else if n <= 0xFFFF { + vec![0xFD, (n & 0xFF) as u8, ((n >> 8) & 0xFF) as u8] + } else if n <= 0xFFFF_FFFF { + vec![ + 0xFE, + (n & 0xFF) as u8, + ((n >> 8) & 0xFF) as u8, + ((n >> 16) & 0xFF) as u8, + ((n >> 24) & 0xFF) as u8, + ] + } else { + vec![ + 0xFF, + (n & 0xFF) as u8, + ((n >> 8) & 0xFF) as u8, + ((n >> 16) & 0xFF) as u8, + ((n >> 24) & 0xFF) as u8, + ((n >> 32) & 0xFF) as u8, + ((n >> 40) & 0xFF) as u8, + ((n >> 48) & 0xFF) as u8, + ((n >> 56) & 0xFF) as u8, + ] + } + } + + for &n in &[ + 0u64, + 1, + 252, + 253, + 65535, + 65536, + 4_294_967_295, + 4_294_967_296, + ] { + let mut h1 = blake3::Hasher::new(); + write_compact_size(n, &mut h1); + let got = h1.finalize(); + + let mut h2 = blake3::Hasher::new(); + h2.update(&expected_bytes(n)); + let exp = h2.finalize(); + + assert_eq!(got, exp, "mismatch for n={n}"); + } + } + + /// Same logical set, different insertion orders. Should get the same digest. + #[test] + fn utxo_set_hash_is_deterministic_and_order_canonicalized() { + let best_block_hash = [0x11; 32]; + + let item_a = vec![ + item(0xAA, 0, 50, 10), + item(0xAA, 1, 60, 0), + item(0xBB, 0, 70, 3), + ]; + + // Shuffled order + let item_b = vec![ + item(0xBB, 0, 70, 3), + item(0xAA, 1, 60, 0), + item(0xAA, 0, 50, 10), + ]; + + let hash_1 = utxo_set_hash_v1( + hex::decode(GENESIS_BLOCK_HASH).unwrap().try_into().unwrap(), + 100, + best_block_hash, + item_a, + ) + .unwrap(); + let hash_2 = utxo_set_hash_v1( + hex::decode(GENESIS_BLOCK_HASH).unwrap().try_into().unwrap(), + 100, + best_block_hash, + item_b, + ) + .unwrap(); + + assert_eq!(hash_1, hash_2); + } + + #[test] + fn utxo_set_hash_changes_with_header_fields() { + let items = [item(0x01, 0, 1, 0)]; + let best_block_hash_1 = [0x22; 32]; + let best_block_hash_2 = [0x23; 32]; + + let different_genesis_hash = [0x00; 32]; + + let base = utxo_set_hash_v1( + hex::decode(GENESIS_BLOCK_HASH).unwrap().try_into().unwrap(), + 1, + best_block_hash_1, + items.clone(), + ) + .unwrap(); + assert_ne!( + base, + utxo_set_hash_v1(different_genesis_hash, 1, best_block_hash_1, items.clone()) + .unwrap(), + "network must affect hash" + ); + assert_ne!( + base, + utxo_set_hash_v1( + hex::decode(GENESIS_BLOCK_HASH).unwrap().try_into().unwrap(), + 2, + best_block_hash_1, + items.clone() + ) + .unwrap(), + "height must affect hash" + ); + assert_ne!( + base, + utxo_set_hash_v1( + hex::decode(GENESIS_BLOCK_HASH).unwrap().try_into().unwrap(), + 1, + best_block_hash_2, + items.clone() + ) + .unwrap(), + "best_block must affect hash" + ); + } + + #[test] + fn utxo_set_hash_changes_when_entry_changes() { + let best_block_hash = [0x99; 32]; + let custom_genesis_hash = [0x00; 32]; + + let base = utxo_set_hash_v1( + custom_genesis_hash, + 123, + best_block_hash, + [item(0x10, 0, 1_000, 5)], + ) + .unwrap(); + + // Change value + let h_val = utxo_set_hash_v1( + custom_genesis_hash, + 123, + best_block_hash, + [item(0x10, 0, 2_000, 5)], + ) + .unwrap(); + assert_ne!(base, h_val); + + // Change index + let h_idx = utxo_set_hash_v1( + custom_genesis_hash, + 123, + best_block_hash, + [item(0x10, 1, 1_000, 5)], + ) + .unwrap(); + assert_ne!(base, h_idx); + + // Change script content/length + let h_scr = utxo_set_hash_v1( + custom_genesis_hash, + 123, + best_block_hash, + [item(0x10, 0, 1_000, 6)], + ) + .unwrap(); + assert_ne!(base, h_scr); + } + + #[test] + fn utxo_set_compactsize_boundary_lengths_affect_hash() { + // Check that going from 252 to 253 (boundary into 0xFD form) changes the hash. + let best_block_hash = [0x55; 32]; + + let h_252 = utxo_set_hash_v1( + hex::decode(GENESIS_BLOCK_HASH).unwrap().try_into().unwrap(), + 7, + best_block_hash, + [SnapshotItem { + txid_raw: [1; 32], + index: 0, + value_zat: 42, + script: vec![0xAA; 252], + }], + ) + .unwrap(); + let h_253 = utxo_set_hash_v1( + hex::decode(GENESIS_BLOCK_HASH).unwrap().try_into().unwrap(), + 7, + best_block_hash, + [SnapshotItem { + txid_raw: [1; 32], + index: 0, + value_zat: 42, + script: vec![0xAA; 253], + }], + ) + .unwrap(); + + assert_ne!(h_252, h_253, "length prefix must change at boundary"); + } + + #[test] + fn duplicate_outpoint_returns_error() { + let mut txid = [0u8; 32]; + txid[0] = 1; + + let a = SnapshotItem { + txid_raw: txid, + index: 0, + value_zat: 1, + script: vec![], + }; + let b = SnapshotItem { + txid_raw: txid, + index: 0, + value_zat: 2, + script: vec![0x51], + }; // same (txid,vout) + + let genesis = [9u8; 32]; + let best = [8u8; 32]; + + let res = utxo_set_hash_v1(genesis, 123, best, vec![a, b]); + + assert!(matches!(res, Err(UtxoSetError::DuplicateOutpoint))); + } + } + + mod byte_order_tests { + + use crate::jsonrpsee::response::{ + common::{amount::ZecAmount, BlockHeight}, + txout_set_info::{ + tests::GENESIS_BLOCK_HASH, + utxo_set_hash::{utxo_set_hash_v1, SnapshotItem, DOMAIN_TAG}, + TxOutSetInfo, + }, + }; + + /// Return a sequence of bytes with a known display order. + fn seq_bytes() -> [u8; 32] { + let mut bytes = [0u8; 32]; + for (i, x) in bytes.iter_mut().enumerate() { + *x = i as u8; + } + bytes + } + + #[test] + fn utxo_set_header_uses_display_order_bytes() { + let height = 123u32; + let display_bytes = seq_bytes(); + + let genesis_block_hash_bytes: [u8; 32] = + hex::decode(GENESIS_BLOCK_HASH).unwrap().try_into().unwrap(); + + let h_func = utxo_set_hash_v1( + genesis_block_hash_bytes, + height, + display_bytes, + std::iter::empty::(), + ) + .unwrap(); + + let mut hasher = blake3::Hasher::new(); + hasher.update(DOMAIN_TAG); + hasher.update(&genesis_block_hash_bytes); + hasher.update(&height.to_le_bytes()); + hasher.update(&display_bytes); + hasher.update(&0u64.to_le_bytes()); + let h_manual = hasher.finalize(); + + assert_eq!( + h_func, h_manual, + "header must be fed with display-order bytes" + ); + } + + #[test] + fn wrong_endianness_changes_digest() { + let height = 7u32; + let display_bytes = seq_bytes(); + + let h_ok = utxo_set_hash_v1( + hex::decode(GENESIS_BLOCK_HASH).unwrap().try_into().unwrap(), + height, + display_bytes, + std::iter::empty::(), + ) + .unwrap(); + + let mut flipped = display_bytes; + flipped.reverse(); + let h_bad = utxo_set_hash_v1( + hex::decode(GENESIS_BLOCK_HASH).unwrap().try_into().unwrap(), + height, + flipped, + std::iter::empty::(), + ) + .unwrap(); + + assert_ne!( + h_ok, h_bad, + "feeding non-display-order bytes must change the digest" + ); + } + + #[test] + fn bestblock_string_matches_bytes_used_in_hash() { + let height = 0u32; + let best_block = seq_bytes(); + let best_block_hex = hex::encode(best_block); + + // Compute digest using display-order bytes + let digest = utxo_set_hash_v1( + [0x00; 32], + height, + best_block, + std::iter::empty::(), + ) + .unwrap(); + + // SAFEST construction: go through the hex string, so display is as stored + let best_block: zebra_chain::block::Hash = + best_block_hex.parse().expect("valid 32-byte hex"); + + let info = TxOutSetInfo { + height: BlockHeight(height), + best_block, + transactions: 0, + tx_outs: 0, + bytes_serialized: 0, + hash_serialized: digest.to_string(), + total_amount: ZecAmount::from_zats(0), + }; + let v = serde_json::to_value(&info).unwrap(); + + // The JSON should carry the same hex we hashed in the header. + assert_eq!(v["bestblock"].as_str().unwrap(), best_block_hex); + } + } + + mod size_tests { + use crate::jsonrpsee::response::txout_set_info::utxo_set_hash::{ + utxo_set_serialized_size_v1, SnapshotItem, DOMAIN_TAG, NETWORK_TAG_LEN, NETWORK_TAG_NUL, + }; + + #[test] + fn header_only_size_is_constant_plus_network() { + let size = utxo_set_serialized_size_v1(std::iter::empty::()); + let expected = DOMAIN_TAG.len() as u64 + + NETWORK_TAG_LEN + + NETWORK_TAG_NUL.len() as u64 + + 4 // u32 height + + 32 + + 8; // total_outputs u64 + assert_eq!(size, expected); + } + + #[test] + fn single_entry_zero_script_size_matches_manual() { + let net = "regtest"; + let best_block = [0xAA; 32]; + let item = SnapshotItem { + txid_raw: [0x11; 32], + index: 2, + value_zat: 42, + script: vec![], + }; + + let counted = utxo_set_serialized_size_v1([item]); + + let mut bytes = Vec::new(); + bytes.extend_from_slice(DOMAIN_TAG); + bytes.extend_from_slice(net.as_bytes()); + bytes.push(0); + bytes.extend_from_slice(&7u32.to_le_bytes()); + bytes.extend_from_slice(&best_block); + bytes.extend_from_slice(&1u64.to_le_bytes()); // total_outputs = 1 + + // entry + bytes.extend_from_slice(&[0x11; 32]); + bytes.extend_from_slice(&2u32.to_le_bytes()); + bytes.extend_from_slice(&42u64.to_le_bytes()); + bytes.push(0); // No script bytes + + assert_eq!(counted as usize, bytes.len()); + } + + #[test] + fn compactsize_thresholds_change_size() { + let mk_item = |len: usize| SnapshotItem { + txid_raw: [0u8; 32], + index: 0, + value_zat: 0, + script: vec![0xAA; len], + }; + + let with_len = |len| utxo_set_serialized_size_v1([mk_item(len)]); + + // 1 byte varint + let varint_a = with_len(252); + // 3 bytes varint + let varint_b = with_len(253); + assert_eq!(varint_b - varint_a, 3); + + // 3 bytes varint + let varint_c = with_len(65535); + // 5 bytes varint + let varint_d = with_len(65536); + assert_eq!(varint_d - varint_c, 3); + } + + #[test] + fn multiple_entries_add_linearly() { + let base = SnapshotItem { + txid_raw: [0x77; 32], + index: 1, + value_zat: 1_000, + script: vec![1, 2, 3, 4], // CompactSize = 1 + }; + + let size_1 = utxo_set_serialized_size_v1([base.clone()]); + let size_2 = utxo_set_serialized_size_v1([base.clone(), base.clone()]); + assert_eq!( + size_2 - size_1, + size_1 + - (DOMAIN_TAG.len() as u64 + + NETWORK_TAG_LEN + + NETWORK_TAG_NUL.len() as u64 + + 4 + + 32 + + 8) + ); + } + } +} diff --git a/zaino-fetch/src/lib.rs b/zaino-fetch/src/lib.rs index 3a0c69e63..279763ccb 100644 --- a/zaino-fetch/src/lib.rs +++ b/zaino-fetch/src/lib.rs @@ -7,3 +7,5 @@ pub mod chain; pub mod jsonrpsee; + +pub use zebra_chain::block::Hash; diff --git a/zaino-serve/src/rpc/jsonrpc/service.rs b/zaino-serve/src/rpc/jsonrpc/service.rs index 77a07032c..9fb892bbe 100644 --- a/zaino-serve/src/rpc/jsonrpc/service.rs +++ b/zaino-serve/src/rpc/jsonrpc/service.rs @@ -3,6 +3,7 @@ use zaino_fetch::jsonrpsee::response::block_subsidy::GetBlockSubsidy; use zaino_fetch::jsonrpsee::response::mining_info::GetMiningInfoWire; use zaino_fetch::jsonrpsee::response::peer_info::GetPeerInfo; +use zaino_fetch::jsonrpsee::response::txout_set_info::GetTxOutSetInfo; use zaino_fetch::jsonrpsee::response::{GetMempoolInfoResponse, GetNetworkSolPsResponse}; use zaino_state::{LightWalletIndexer, ZcashIndexer}; @@ -104,6 +105,19 @@ pub trait ZcashIndexerRpc { #[method(name = "getpeerinfo")] async fn get_peer_info(&self) -> Result; + /// Returns statistics about the unspent transaction output set. + /// Note this call may take some time. + /// + /// zcashd reference: [`gettxoutsetinfo`](https://zcash.github.io/rpc/gettxoutsetinfo.html) + /// method: post + /// tags: blockchain + /// + /// # Notes + /// + /// Only `zcashd` supports this method. Zebra has no intention of supporting it. + #[method(name = "gettxoutsetinfo")] + async fn get_txout_set_info(&self) -> Result; + /// Returns block subsidy reward, taking into account the mining slow start and the founders reward, of block at index provided. /// /// zcashd reference: [`getblocksubsidy`](https://zcash.github.io/rpc/getblocksubsidy.html) @@ -462,6 +476,20 @@ impl ZcashIndexerRpcServer for JsonR }) } + async fn get_txout_set_info(&self) -> Result { + self.service_subscriber + .inner_ref() + .get_txout_set_info() + .await + .map_err(|e| { + ErrorObjectOwned::owned( + ErrorCode::InvalidParams.code(), + "Internal server error", + Some(e.to_string()), + ) + }) + } + async fn get_block_subsidy(&self, height: u32) -> Result { self.service_subscriber .inner_ref() diff --git a/zaino-state/src/backends/fetch.rs b/zaino-state/src/backends/fetch.rs index 714fcf798..a8b052d08 100644 --- a/zaino-state/src/backends/fetch.rs +++ b/zaino-state/src/backends/fetch.rs @@ -26,6 +26,7 @@ use zaino_fetch::{ block_subsidy::GetBlockSubsidy, mining_info::GetMiningInfoWire, peer_info::GetPeerInfo, + txout_set_info::GetTxOutSetInfo, {GetMempoolInfoResponse, GetNetworkSolPsResponse}, }, }, @@ -577,6 +578,11 @@ impl ZcashIndexer for FetchServiceSubscriber { .transactions) } + /// Note: This method has only been implemented in `zcashd`. Zebra has no intention of supporting it. + async fn get_txout_set_info(&self) -> Result { + Ok(self.fetcher.get_txout_set_info().await?) + } + /// Returns all unspent outputs for a list of addresses. /// /// zcashd reference: [`getaddressutxos`](https://zcash.github.io/rpc/getaddressutxos.html) diff --git a/zaino-state/src/backends/state.rs b/zaino-state/src/backends/state.rs index 5b7c12164..4f9d77c02 100644 --- a/zaino-state/src/backends/state.rs +++ b/zaino-state/src/backends/state.rs @@ -28,7 +28,11 @@ use zaino_fetch::{ jsonrpsee::{ connector::{JsonRpSeeConnector, RpcError}, response::{ - block_subsidy::GetBlockSubsidy, mining_info::GetMiningInfoWire, peer_info::GetPeerInfo, + block_subsidy::GetBlockSubsidy, + common::{amount::ZecAmount, BlockHeight}, + mining_info::GetMiningInfoWire, + peer_info::GetPeerInfo, + txout_set_info::{self, GetTxOutSetInfo, TxOutSetInfo}, GetMempoolInfoResponse, GetNetworkSolPsResponse, GetSubtreesResponse, }, }, @@ -44,11 +48,13 @@ use zaino_proto::proto::{ use zcash_protocol::consensus::NetworkType; use zebra_chain::{ + amount::NonNegative, block::{Header, Height, SerializedBlock}, chain_tip::NetworkChainTipHeightEstimator, parameters::{ConsensusBranchId, Network, NetworkKind, NetworkUpgrade}, - serialization::ZcashSerialize, + serialization::{ZcashDeserialize, ZcashSerialize}, subtree::NoteCommitmentSubtreeIndex, + transaction, transparent, }; use zebra_rpc::{ client::{ @@ -74,7 +80,12 @@ use chrono::{DateTime, Utc}; use futures::{TryFutureExt as _, TryStreamExt as _}; use hex::{FromHex as _, ToHex}; use indexmap::IndexMap; -use std::{collections::HashSet, future::poll_fn, str::FromStr, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + future::poll_fn, + str::FromStr, + sync::Arc, +}; use tokio::{ sync::mpsc, time::{self, timeout}, @@ -875,6 +886,64 @@ impl StateServiceSubscriber { } } + /// Fetches all UTXOs from the state service, and returns them in a map. + pub(crate) async fn get_txout_set( + state: &ReadStateService, + ) -> Result, StateServiceError> { + let mut utxos: HashMap = HashMap::new(); + + let mut state = state.clone(); + + let zebra_state::ReadResponse::Tip { 0: tip } = state + .ready() + .and_then(|service| service.call(zebra_state::ReadRequest::Tip)) + .await + .map_err(|_| { + StateServiceError::Custom("Failed to get state service tip".to_string()) + })? + else { + return Err(StateServiceError::Custom( + "Unexpected response to BlockHeader request".to_string(), + )); + }; + + let (tip_h, _) = match tip { + Some(tip) => tip, + _ => return Ok(utxos), + }; + + for h in 0..=tip_h.0 { + let blk = state + .call(ReadRequest::Block(HashOrHeight::Height(Height(h)))) + .await + .unwrap(); + let block = match blk { + ReadResponse::Block(Some(b)) => b, + _ => continue, + }; + + for tx in block.transactions.clone() { + let txid = tx.hash(); + // Add outputs + for (i, output) in tx.outputs().iter().enumerate() { + let op = transparent::OutPoint { + hash: txid, + index: i as u32, + }; + utxos.insert(op, output.clone()); + } + // Remove spends + for input in tx.inputs() { + if let Some(prev) = input.outpoint() { + utxos.remove(&prev); + } + } + } + } + + Ok(utxos) + } + /// Returns the network type running. #[allow(deprecated)] pub fn network(&self) -> zaino_common::Network { @@ -1510,6 +1579,94 @@ impl ZcashIndexer for StateServiceSubscriber { .collect()) } + async fn get_txout_set_info(&self) -> Result { + let txouts = Self::get_txout_set(&self.read_state_service).await.unwrap(); + + let best_block_hash = self.get_best_blockhash().await.unwrap().hash(); + let best_block_height = match self + .z_get_block(best_block_hash.to_string(), None) + .await + .unwrap() + { + GetBlock::Raw(raw_object) => { + // From Zebra: guaranteed to be deserializable into a `Block`. + let block = + zebra_chain::block::Block::zcash_deserialize(raw_object.as_ref()).unwrap(); + block.coinbase_height().unwrap() // From Zebra: Verified blocks have a valid height. + } + GetBlock::Object(block_object) => block_object.height().expect("expected height"), + }; + + let genesis_block_hash = match self.z_get_block("0".to_string(), None).await.unwrap() { + GetBlock::Raw(raw_object) => { + // From Zebra: guaranteed to be deserializable into a `Block`. + let block = + zebra_chain::block::Block::zcash_deserialize(raw_object.as_ref()).unwrap(); + block.hash() + } + GetBlock::Object(block_object) => block_object.hash(), + }; + + let mut unique_txs = HashSet::::with_capacity(txouts.len()); + for op in txouts.keys() { + let _ = unique_txs.insert(op.hash); + } + + let total_amt: Result< + zebra_chain::amount::Amount, + zebra_chain::amount::Error, + > = txouts.values().map(|txout| txout.value).sum(); + + let items: Vec = txouts + .iter() + .map(|(op, txout)| { + txout_set_info::utxo_set_hash::SnapshotItem { + index: op.index, + // From zebra: serialization MUST be infallible up to errors in the underlying writer. + script: txout.lock_script.zcash_serialize_to_vec().unwrap(), + txid_raw: op.hash.0, + // `txout.value.zatoshis()` is already enforced to be non-negative. + // `txout.value` is of type `Amount`. + value_zat: u64::try_from(txout.value.zatoshis()).unwrap(), + } + }) + .collect(); + + let utxo_set_hash = match txout_set_info::utxo_set_hash::utxo_set_hash_v1( + genesis_block_hash.0, + best_block_height.0, + best_block_hash.bytes_in_display_order(), + items.clone(), + ) { + Ok(utxo_set_hash) => utxo_set_hash, + Err(e) => { + return Err(StateServiceError::RpcError(RpcError::new_from_legacycode( + LegacyCode::Misc, + format!( + "Failed to compute UTXO set hash. UTXO set contains duplicated entries: {}", + e + ), + ))) + } + }; + + let utxo_serialized_size = + txout_set_info::utxo_set_hash::utxo_set_serialized_size_v1(items); + + // This cannot fail because `txout.value.zatoshis()` cannot be negative + let total_zats: u64 = u64::from(total_amt.unwrap()); + + Ok(GetTxOutSetInfo::Known(TxOutSetInfo { + height: BlockHeight(best_block_height.0), + best_block: best_block_hash, + transactions: unique_txs.len() as u64, + tx_outs: txouts.len() as u64, + bytes_serialized: utxo_serialized_size, + hash_serialized: utxo_set_hash.to_string(), + total_amount: ZecAmount::from_zats(total_zats), + })) + } + async fn z_get_address_utxos( &self, address_strings: AddressStrings, diff --git a/zaino-state/src/indexer.rs b/zaino-state/src/indexer.rs index 96aa0403a..7be4f269b 100644 --- a/zaino-state/src/indexer.rs +++ b/zaino-state/src/indexer.rs @@ -8,6 +8,7 @@ use zaino_fetch::jsonrpsee::response::{ block_subsidy::GetBlockSubsidy, mining_info::GetMiningInfoWire, peer_info::GetPeerInfo, + txout_set_info::GetTxOutSetInfo, {GetMempoolInfoResponse, GetNetworkSolPsResponse}, }; use zaino_proto::proto::{ @@ -413,6 +414,18 @@ pub trait ZcashIndexer: Send + Sync + 'static { request: GetAddressTxIdsRequest, ) -> Result, Self::Error>; + /// Returns statistics about the unspent transaction output set. + /// Note this call may take some time. + /// + /// zcashd reference: [`gettxoutsetinfo`](https://zcash.github.io/rpc/gettxoutsetinfo.html) + /// method: post + /// tags: blockchain + /// + /// # Notes + /// + /// Only `zcashd` supports this method. Zebra has no intention of supporting it. + async fn get_txout_set_info(&self) -> Result; + /// Returns all unspent outputs for a list of addresses. /// /// zcashd reference: [`getaddressutxos`](https://zcash.github.io/rpc/getaddressutxos.html)