Skip to main content

vote_commitment_tree_client/
types.rs

1//! JSON deserialization types matching the Go chain's REST API responses.
2//!
3//! The chain uses `encoding/json.Marshal` on protobuf-generated types, so:
4//! - Field names are snake_case (from proto `json` tags)
5//! - `[]byte` fields are base64-encoded strings
6//! - `uint64` fields are JSON numbers
7//! - `omitempty` means zero/nil fields may be absent
8
9use base64::prelude::*;
10use ff::PrimeField;
11use pasta_curves::Fp;
12use serde::Deserialize;
13
14use vote_commitment_tree::sync_api::{BlockCommitments, BlockCommitmentsPage, TreeState};
15use vote_commitment_tree::MerkleHashVote;
16
17// ---------------------------------------------------------------------------
18// Error
19// ---------------------------------------------------------------------------
20
21/// Errors from parsing chain JSON responses into domain types.
22#[derive(Debug, thiserror::Error)]
23pub enum ParseError {
24    #[error("missing field: {0}")]
25    MissingField(&'static str),
26
27    #[error("base64 decode error: {0}")]
28    Base64(#[from] base64::DecodeError),
29
30    #[error("invalid Fp encoding ({context}): expected 32 bytes, got {len}")]
31    InvalidFpLength { context: &'static str, len: usize },
32
33    #[error("non-canonical Fp encoding ({context})")]
34    NonCanonicalFp { context: &'static str },
35}
36
37// ---------------------------------------------------------------------------
38// Raw JSON shapes (1:1 with Go JSON output)
39// ---------------------------------------------------------------------------
40
41/// Matches Go `CommitmentTreeState` JSON serialization.
42#[derive(Debug, Deserialize)]
43pub(crate) struct ChainTreeState {
44    #[serde(default)]
45    pub next_index: u64,
46    /// Base64-encoded 32-byte Pallas Fp (little-endian).
47    #[serde(default)]
48    pub root: Option<String>,
49    #[serde(default)]
50    pub height: u64,
51}
52
53/// Matches Go `BlockCommitments` JSON serialization.
54#[derive(Debug, Deserialize)]
55pub(crate) struct ChainBlockCommitments {
56    #[serde(default)]
57    pub height: u64,
58    #[serde(default)]
59    pub start_index: u64,
60    /// Each entry is a base64-encoded 32-byte Pallas Fp (little-endian).
61    #[serde(default)]
62    pub leaves: Vec<String>,
63    /// Base64-encoded 32-byte Pallas Fp root after this block.
64    #[serde(default)]
65    pub root: Option<String>,
66}
67
68/// `GET /zally/v1/commitment-tree/latest` response.
69#[derive(Debug, Deserialize)]
70pub(crate) struct QueryLatestTreeResponse {
71    pub tree: Option<ChainTreeState>,
72}
73
74/// `GET /zally/v1/commitment-tree/{height}` response.
75#[derive(Debug, Deserialize)]
76pub(crate) struct QueryCommitmentTreeResponse {
77    pub tree: Option<ChainTreeState>,
78}
79
80/// `GET /zally/v1/commitment-tree/leaves` response.
81#[derive(Debug, Deserialize)]
82pub(crate) struct QueryCommitmentLeavesResponse {
83    #[serde(default)]
84    pub blocks: Vec<ChainBlockCommitments>,
85    #[serde(default)]
86    pub next_from_height: u64,
87}
88
89// ---------------------------------------------------------------------------
90// Conversions: raw JSON → domain types
91// ---------------------------------------------------------------------------
92
93/// Decode a base64 string into a 32-byte array representing a Pallas Fp element.
94fn decode_fp_base64(b64: &str, context: &'static str) -> Result<Fp, ParseError> {
95    let bytes = BASE64_STANDARD.decode(b64)?;
96    if bytes.len() != 32 {
97        return Err(ParseError::InvalidFpLength {
98            context,
99            len: bytes.len(),
100        });
101    }
102    let mut arr = [0u8; 32];
103    arr.copy_from_slice(&bytes);
104    Option::from(Fp::from_repr(arr)).ok_or(ParseError::NonCanonicalFp { context })
105}
106
107impl ChainTreeState {
108    /// Convert to the domain `TreeState`.
109    pub fn into_tree_state(self) -> Result<TreeState, ParseError> {
110        let root = match &self.root {
111            Some(b64) if !b64.is_empty() => decode_fp_base64(b64, "tree_state.root")?,
112            _ => Fp::zero(),
113        };
114        Ok(TreeState {
115            next_index: self.next_index,
116            root,
117            height: self.height as u32,
118        })
119    }
120}
121
122impl ChainBlockCommitments {
123    /// Convert to the domain `BlockCommitments`.
124    pub fn into_block_commitments(self) -> Result<BlockCommitments, ParseError> {
125        let mut leaves = Vec::with_capacity(self.leaves.len());
126        for (i, b64) in self.leaves.iter().enumerate() {
127            let fp = decode_fp_base64(b64, "block_commitments.leaf")?;
128            leaves.push(MerkleHashVote::from_fp(fp));
129            let _ = i; // suppress unused warning in non-debug
130        }
131        Ok(BlockCommitments {
132            height: self.height as u32,
133            start_index: self.start_index,
134            leaves,
135            root: decode_fp_base64(
136                self.root
137                    .as_deref()
138                    .ok_or(ParseError::MissingField("block_commitments.root"))?,
139                "block_commitments.root",
140            )?,
141        })
142    }
143}
144
145impl QueryCommitmentLeavesResponse {
146    /// Convert to the domain `BlockCommitmentsPage`.
147    pub fn into_block_commitments_page(self) -> Result<BlockCommitmentsPage, ParseError> {
148        let blocks = self
149            .blocks
150            .into_iter()
151            .map(ChainBlockCommitments::into_block_commitments)
152            .collect::<Result<Vec<_>, _>>()?;
153        Ok(BlockCommitmentsPage {
154            blocks,
155            next_from_height: self.next_from_height as u32,
156        })
157    }
158}
159
160// ---------------------------------------------------------------------------
161// Tests
162// ---------------------------------------------------------------------------
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn parse_tree_state_full() {
170        // Fp::zero() is all-zero bytes → base64 of 32 zero bytes
171        let zero_b64 = BASE64_STANDARD.encode([0u8; 32]);
172        let json = format!(
173            r#"{{"tree":{{"next_index":42,"root":"{}","height":10}}}}"#,
174            zero_b64
175        );
176        let resp: QueryLatestTreeResponse = serde_json::from_str(&json).unwrap();
177        let state = resp.tree.unwrap().into_tree_state().unwrap();
178        assert_eq!(state.next_index, 42);
179        assert_eq!(state.height, 10);
180        assert_eq!(state.root, Fp::zero());
181    }
182
183    #[test]
184    fn parse_tree_state_missing_root() {
185        let json = r#"{"tree":{"next_index":0,"height":0}}"#;
186        let resp: QueryLatestTreeResponse = serde_json::from_str(json).unwrap();
187        let state = resp.tree.unwrap().into_tree_state().unwrap();
188        assert_eq!(state.root, Fp::zero());
189    }
190
191    #[test]
192    fn parse_tree_state_null_tree() {
193        let json = r#"{"tree":null}"#;
194        let resp: QueryLatestTreeResponse = serde_json::from_str(json).unwrap();
195        assert!(resp.tree.is_none());
196    }
197
198    #[test]
199    fn parse_block_commitments_with_leaves() {
200        // Fp::from(1) = [1, 0, 0, ..., 0] (32 bytes LE)
201        let one_bytes = Fp::from(1).to_repr();
202        let one_b64 = BASE64_STANDARD.encode(one_bytes);
203        let json = format!(
204            r#"{{"blocks":[{{"height":5,"start_index":0,"leaves":["{}","{}"],"root":"{}"}}],"next_from_height":9}}"#,
205            one_b64, one_b64, one_b64
206        );
207        let resp: QueryCommitmentLeavesResponse = serde_json::from_str(&json).unwrap();
208        let page = resp.into_block_commitments_page().unwrap();
209        assert_eq!(page.next_from_height, 9);
210        assert_eq!(page.blocks.len(), 1);
211        let block = &page.blocks[0];
212        assert_eq!(block.height, 5);
213        assert_eq!(block.start_index, 0);
214        assert_eq!(block.leaves.len(), 2);
215        assert_eq!(block.leaves[0].inner(), Fp::from(1));
216        assert_eq!(block.root, Fp::from(1));
217    }
218
219    #[test]
220    fn parse_empty_blocks() {
221        let json = r#"{"blocks":[],"next_from_height":0}"#;
222        let resp: QueryCommitmentLeavesResponse = serde_json::from_str(json).unwrap();
223        let page = resp.into_block_commitments_page().unwrap();
224        assert!(page.blocks.is_empty());
225        assert_eq!(page.next_from_height, 0);
226    }
227
228    #[test]
229    fn parse_omitted_blocks_field() {
230        // Go's omitempty may omit the blocks field entirely.
231        let json = r#"{}"#;
232        let resp: QueryCommitmentLeavesResponse = serde_json::from_str(json).unwrap();
233        let page = resp.into_block_commitments_page().unwrap();
234        assert!(page.blocks.is_empty());
235        assert_eq!(page.next_from_height, 0);
236    }
237
238    #[test]
239    fn parse_block_commitments_rejects_missing_root() {
240        let one_b64 = BASE64_STANDARD.encode(Fp::from(1).to_repr());
241        let json = format!(
242            r#"{{"blocks":[{{"height":5,"start_index":0,"leaves":["{}"]}}]}}"#,
243            one_b64
244        );
245        let resp: QueryCommitmentLeavesResponse = serde_json::from_str(&json).unwrap();
246        let err = resp.into_block_commitments_page().unwrap_err();
247        assert!(matches!(
248            err,
249            ParseError::MissingField("block_commitments.root")
250        ));
251    }
252
253    #[test]
254    fn decode_fp_rejects_short_base64() {
255        let short = BASE64_STANDARD.encode([0u8; 16]);
256        let err = decode_fp_base64(&short, "test").unwrap_err();
257        assert!(matches!(err, ParseError::InvalidFpLength { len: 16, .. }));
258    }
259
260    #[test]
261    fn decode_fp_rejects_non_canonical() {
262        // All 0xFF bytes is larger than the Pallas modulus → non-canonical.
263        let bad = BASE64_STANDARD.encode([0xFF; 32]);
264        let err = decode_fp_base64(&bad, "test").unwrap_err();
265        assert!(matches!(err, ParseError::NonCanonicalFp { .. }));
266    }
267}