zecscope_scanner/
scanner.rs

1//! Core scanner implementation.
2
3use crate::error::{ScanError, ScanResult};
4use crate::types::*;
5use zcash_client_backend::{
6    data_api::BlockMetadata,
7    proto::compact_formats,
8    scanning::{scan_block, Nullifiers, ScanningKeys},
9};
10use zcash_keys::keys::UnifiedFullViewingKey;
11use zcash_protocol::consensus::Network;
12use zip32::Scope;
13
14/// High-level scanner for Zcash shielded transactions.
15///
16/// The scanner takes compact blocks and a viewing key, and returns
17/// all transactions visible to that key.
18pub struct Scanner {
19    network: Network,
20}
21
22impl Scanner {
23    /// Create a new scanner for the given network.
24    pub fn new(network: Network) -> Self {
25        Self { network }
26    }
27
28    /// Create a scanner for mainnet.
29    pub fn mainnet() -> Self {
30        Self::new(Network::MainNetwork)
31    }
32
33    /// Create a scanner for testnet.
34    pub fn testnet() -> Self {
35        Self::new(Network::TestNetwork)
36    }
37
38    /// Scan compact blocks with a viewing key.
39    ///
40    /// Returns all transactions visible to the viewing key in the given blocks.
41    pub fn scan(&self, request: &ScanRequest) -> ScanResult<Vec<ZecTransaction>> {
42        // Normalize viewing key (strip any |uivk... suffix)
43        let viewing_key = normalize_viewing_key(&request.viewing_key);
44
45        // Decode the UFVK
46        let ufvk = UnifiedFullViewingKey::decode(&self.network, &viewing_key)
47            .map_err(|e| ScanError::InvalidViewingKey(e.to_string()))?;
48
49        // Convert compact blocks to protobuf format
50        let blocks = request
51            .compact_blocks
52            .iter()
53            .map(|b| map_compact_block(b))
54            .collect::<ScanResult<Vec<_>>>()?;
55
56        // Set up scanning keys
57        type AccountId = u32;
58        let scanning_keys: ScanningKeys<AccountId, (AccountId, Scope)> =
59            ScanningKeys::from_account_ufvks(std::iter::once((0u32, ufvk)));
60        let nullifiers = Nullifiers::<AccountId>::empty();
61
62        let mut prior_meta: Option<BlockMetadata> = None;
63        let mut transactions = Vec::new();
64
65        for block in blocks {
66            let scanned = scan_block(
67                &self.network,
68                block,
69                &scanning_keys,
70                &nullifiers,
71                prior_meta.as_ref(),
72            )
73            .map_err(|e| ScanError::ScanFailed {
74                height: e.at_height().into(),
75                message: e.to_string(),
76            })?;
77
78            let height: u32 = scanned.height().into();
79            let height = height as u64;
80            let time = scanned.block_time() as i64;
81
82            for wtx in scanned.transactions() {
83                let txid = wtx.txid();
84                let txid_hex = hex::encode(txid.as_ref());
85
86                // Process Sapling outputs
87                for out in wtx.sapling_outputs() {
88                    if out.is_change() {
89                        continue;
90                    }
91                    let note = out.note();
92                    let v = note.value().inner();
93                    if v == 0 {
94                        continue;
95                    }
96
97                    transactions.push(ZecTransaction {
98                        txid: txid_hex.clone(),
99                        height,
100                        time,
101                        amount_zat: v.to_string(),
102                        direction: TxDirection::In,
103                        memo: None,
104                        key_id: request.key_id.clone(),
105                        pool: ShieldedPool::Sapling,
106                    });
107                }
108
109                // Process Orchard outputs
110                #[cfg(feature = "orchard")]
111                for out in wtx.orchard_outputs() {
112                    if out.is_change() {
113                        continue;
114                    }
115                    let note = out.note();
116                    let v: u64 = note.value().inner();
117                    if v == 0 {
118                        continue;
119                    }
120
121                    transactions.push(ZecTransaction {
122                        txid: txid_hex.clone(),
123                        height,
124                        time,
125                        amount_zat: v.to_string(),
126                        direction: TxDirection::In,
127                        memo: None,
128                        key_id: request.key_id.clone(),
129                        pool: ShieldedPool::Orchard,
130                    });
131                }
132            }
133
134            prior_meta = Some(scanned.to_block_metadata());
135        }
136
137        Ok(transactions)
138    }
139
140    /// Scan compact blocks from JSON string.
141    ///
142    /// This is a convenience method for WASM and other environments
143    /// where JSON is the primary data format.
144    pub fn scan_json(&self, request_json: &str) -> ScanResult<String> {
145        let request: ScanRequest = serde_json::from_str(request_json)?;
146        let transactions = self.scan(&request)?;
147        Ok(serde_json::to_string(&transactions)?)
148    }
149}
150
151/// Normalize a viewing key string.
152///
153/// Some tools export UFVKs with an appended `|uivk...` segment.
154/// This function strips that suffix to get just the UFVK.
155fn normalize_viewing_key(raw: &str) -> String {
156    let trimmed = raw.trim();
157    if let Some(idx) = trimmed.find('|') {
158        trimmed[..idx].to_string()
159    } else {
160        trimmed.to_string()
161    }
162}
163
164/// Decode a hex string, returning a descriptive error.
165fn decode_hex(s: &str, field: &str) -> ScanResult<Vec<u8>> {
166    hex::decode(s).map_err(|e| ScanError::InvalidHex {
167        field: field.to_string(),
168        message: e.to_string(),
169    })
170}
171
172/// Convert our CompactBlock type to the protobuf format.
173fn map_compact_block(block: &CompactBlock) -> ScanResult<compact_formats::CompactBlock> {
174    let vtx = block
175        .vtx
176        .iter()
177        .map(map_compact_tx)
178        .collect::<ScanResult<Vec<_>>>()?;
179
180    let chain_metadata = block.chain_metadata.as_ref().map(|m| {
181        compact_formats::ChainMetadata {
182            sapling_commitment_tree_size: m.sapling_commitment_tree_size,
183            orchard_commitment_tree_size: m.orchard_commitment_tree_size.unwrap_or(0),
184        }
185    });
186
187    Ok(compact_formats::CompactBlock {
188        proto_version: block.proto_version,
189        height: block.height,
190        hash: decode_hex(&block.hash, "block hash")?,
191        prev_hash: decode_hex(&block.prev_hash, "block prevHash")?,
192        time: block.time,
193        header: Vec::new(),
194        vtx,
195        chain_metadata,
196    })
197}
198
199/// Convert our CompactTx type to the protobuf format.
200fn map_compact_tx(tx: &CompactTx) -> ScanResult<compact_formats::CompactTx> {
201    let hash = decode_hex(&tx.txid, "txid")?;
202
203    let spends = tx
204        .spends
205        .iter()
206        .map(|s| {
207            Ok(compact_formats::CompactSaplingSpend {
208                nf: decode_hex(&s.nf, "sapling spend nf")?,
209            })
210        })
211        .collect::<ScanResult<Vec<_>>>()?;
212
213    let outputs = tx
214        .outputs
215        .iter()
216        .map(|o| {
217            Ok(compact_formats::CompactSaplingOutput {
218                cmu: decode_hex(&o.cmu, "sapling output cmu")?,
219                ephemeral_key: decode_hex(&o.ephemeral_key, "sapling output ephemeralKey")?,
220                ciphertext: decode_hex(&o.ciphertext, "sapling output ciphertext")?,
221            })
222        })
223        .collect::<ScanResult<Vec<_>>>()?;
224
225    let actions = tx
226        .actions
227        .iter()
228        .map(|a| {
229            Ok(compact_formats::CompactOrchardAction {
230                nullifier: decode_hex(&a.nf, "orchard action nf")?,
231                cmx: decode_hex(&a.cmx, "orchard action cmx")?,
232                ephemeral_key: decode_hex(&a.ephemeral_key, "orchard action ephemeralKey")?,
233                ciphertext: decode_hex(&a.ciphertext, "orchard action ciphertext")?,
234            })
235        })
236        .collect::<ScanResult<Vec<_>>>()?;
237
238    Ok(compact_formats::CompactTx {
239        index: tx.index,
240        hash,
241        fee: tx.fee.unwrap_or(0),
242        spends,
243        outputs,
244        actions,
245    })
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn test_normalize_viewing_key() {
254        // Normal UFVK
255        assert_eq!(
256            normalize_viewing_key("uview1abc123"),
257            "uview1abc123"
258        );
259
260        // UFVK with UIVK suffix
261        assert_eq!(
262            normalize_viewing_key("uview1abc123|uivk1xyz789"),
263            "uview1abc123"
264        );
265
266        // With whitespace
267        assert_eq!(
268            normalize_viewing_key("  uview1abc123  "),
269            "uview1abc123"
270        );
271    }
272}