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::MerkleHashVote;
15use vote_commitment_tree::sync_api::{BlockCommitments, TreeState};
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}
64
65/// `GET /zally/v1/commitment-tree/latest` response.
66#[derive(Debug, Deserialize)]
67pub(crate) struct QueryLatestTreeResponse {
68    pub tree: Option<ChainTreeState>,
69}
70
71/// `GET /zally/v1/commitment-tree/{height}` response.
72#[derive(Debug, Deserialize)]
73pub(crate) struct QueryCommitmentTreeResponse {
74    pub tree: Option<ChainTreeState>,
75}
76
77/// `GET /zally/v1/commitment-tree/leaves` response.
78#[derive(Debug, Deserialize)]
79pub(crate) struct QueryCommitmentLeavesResponse {
80    #[serde(default)]
81    pub blocks: Vec<ChainBlockCommitments>,
82}
83
84// ---------------------------------------------------------------------------
85// Conversions: raw JSON → domain types
86// ---------------------------------------------------------------------------
87
88/// Decode a base64 string into a 32-byte array representing a Pallas Fp element.
89fn decode_fp_base64(b64: &str, context: &'static str) -> Result<Fp, ParseError> {
90    let bytes = BASE64_STANDARD.decode(b64)?;
91    if bytes.len() != 32 {
92        return Err(ParseError::InvalidFpLength {
93            context,
94            len: bytes.len(),
95        });
96    }
97    let mut arr = [0u8; 32];
98    arr.copy_from_slice(&bytes);
99    Option::from(Fp::from_repr(arr)).ok_or(ParseError::NonCanonicalFp { context })
100}
101
102impl ChainTreeState {
103    /// Convert to the domain `TreeState`.
104    pub fn into_tree_state(self) -> Result<TreeState, ParseError> {
105        let root = match &self.root {
106            Some(b64) if !b64.is_empty() => decode_fp_base64(b64, "tree_state.root")?,
107            _ => Fp::zero(),
108        };
109        Ok(TreeState {
110            next_index: self.next_index,
111            root,
112            height: self.height as u32,
113        })
114    }
115}
116
117impl ChainBlockCommitments {
118    /// Convert to the domain `BlockCommitments`.
119    pub fn into_block_commitments(self) -> Result<BlockCommitments, ParseError> {
120        let mut leaves = Vec::with_capacity(self.leaves.len());
121        for (i, b64) in self.leaves.iter().enumerate() {
122            let fp = decode_fp_base64(b64, "block_commitments.leaf")?;
123            leaves.push(MerkleHashVote::from_fp(fp));
124            let _ = i; // suppress unused warning in non-debug
125        }
126        Ok(BlockCommitments {
127            height: self.height as u32,
128            start_index: self.start_index,
129            leaves,
130        })
131    }
132}
133
134// ---------------------------------------------------------------------------
135// Tests
136// ---------------------------------------------------------------------------
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn parse_tree_state_full() {
144        // Fp::zero() is all-zero bytes → base64 of 32 zero bytes
145        let zero_b64 = BASE64_STANDARD.encode([0u8; 32]);
146        let json = format!(
147            r#"{{"tree":{{"next_index":42,"root":"{}","height":10}}}}"#,
148            zero_b64
149        );
150        let resp: QueryLatestTreeResponse = serde_json::from_str(&json).unwrap();
151        let state = resp.tree.unwrap().into_tree_state().unwrap();
152        assert_eq!(state.next_index, 42);
153        assert_eq!(state.height, 10);
154        assert_eq!(state.root, Fp::zero());
155    }
156
157    #[test]
158    fn parse_tree_state_missing_root() {
159        let json = r#"{"tree":{"next_index":0,"height":0}}"#;
160        let resp: QueryLatestTreeResponse = serde_json::from_str(json).unwrap();
161        let state = resp.tree.unwrap().into_tree_state().unwrap();
162        assert_eq!(state.root, Fp::zero());
163    }
164
165    #[test]
166    fn parse_tree_state_null_tree() {
167        let json = r#"{"tree":null}"#;
168        let resp: QueryLatestTreeResponse = serde_json::from_str(json).unwrap();
169        assert!(resp.tree.is_none());
170    }
171
172    #[test]
173    fn parse_block_commitments_with_leaves() {
174        // Fp::from(1) = [1, 0, 0, ..., 0] (32 bytes LE)
175        let one_bytes = Fp::from(1).to_repr();
176        let one_b64 = BASE64_STANDARD.encode(one_bytes);
177        let json = format!(
178            r#"{{"blocks":[{{"height":5,"start_index":0,"leaves":["{}","{}"]}}]}}"#,
179            one_b64, one_b64
180        );
181        let resp: QueryCommitmentLeavesResponse = serde_json::from_str(&json).unwrap();
182        assert_eq!(resp.blocks.len(), 1);
183        let block = resp.blocks.into_iter().next().unwrap().into_block_commitments().unwrap();
184        assert_eq!(block.height, 5);
185        assert_eq!(block.start_index, 0);
186        assert_eq!(block.leaves.len(), 2);
187        assert_eq!(block.leaves[0].inner(), Fp::from(1));
188    }
189
190    #[test]
191    fn parse_empty_blocks() {
192        let json = r#"{"blocks":[]}"#;
193        let resp: QueryCommitmentLeavesResponse = serde_json::from_str(json).unwrap();
194        assert!(resp.blocks.is_empty());
195    }
196
197    #[test]
198    fn parse_omitted_blocks_field() {
199        // Go's omitempty may omit the blocks field entirely.
200        let json = r#"{}"#;
201        let resp: QueryCommitmentLeavesResponse = serde_json::from_str(json).unwrap();
202        assert!(resp.blocks.is_empty());
203    }
204
205    #[test]
206    fn decode_fp_rejects_short_base64() {
207        let short = BASE64_STANDARD.encode([0u8; 16]);
208        let err = decode_fp_base64(&short, "test").unwrap_err();
209        assert!(matches!(err, ParseError::InvalidFpLength { len: 16, .. }));
210    }
211
212    #[test]
213    fn decode_fp_rejects_non_canonical() {
214        // All 0xFF bytes is larger than the Pallas modulus → non-canonical.
215        let bad = BASE64_STANDARD.encode([0xFF; 32]);
216        let err = decode_fp_base64(&bad, "test").unwrap_err();
217        assert!(matches!(err, ParseError::NonCanonicalFp { .. }));
218    }
219}