Skip to main content

quantus_cli/subsquid/
client.rs

1//! Subsquid GraphQL client for privacy-preserving transfer queries.
2
3use crate::error::{QuantusError, Result};
4use reqwest::Client;
5use serde::{Deserialize, Serialize};
6
7use super::types::{GraphQLResponse, Transfer, TransferQueryParams, TransfersByPrefixResult};
8
9/// Client for querying the Subsquid indexer.
10pub struct SubsquidClient {
11	url: String,
12	http_client: Client,
13}
14
15#[derive(Serialize)]
16struct GraphQLRequest {
17	query: String,
18	variables: serde_json::Value,
19}
20
21#[derive(Deserialize)]
22struct TransfersByHashPrefixData {
23	#[serde(rename = "transfersByHashPrefix")]
24	transfers_by_hash_prefix: TransfersByPrefixResult,
25}
26
27impl SubsquidClient {
28	/// Create a new Subsquid client.
29	///
30	/// # Arguments
31	///
32	/// * `url` - The GraphQL endpoint URL (e.g., "https://indexer.quantus.com/graphql")
33	pub fn new(url: String) -> Result<Self> {
34		let http_client = Client::builder()
35			.build()
36			.map_err(|e| QuantusError::Generic(format!("Failed to create HTTP client: {}", e)))?;
37
38		Ok(Self { url, http_client })
39	}
40
41	/// Query transfers by hash prefixes.
42	///
43	/// This method allows privacy-preserving queries by matching against
44	/// blake3 hash prefixes of addresses rather than the addresses themselves.
45	///
46	/// # Arguments
47	///
48	/// * `to_prefixes` - Hash prefixes for destination addresses (OR logic)
49	/// * `from_prefixes` - Hash prefixes for source addresses (OR logic)
50	/// * `params` - Additional query parameters (block range, amount filters, pagination)
51	///
52	/// # Returns
53	///
54	/// A list of matching transfers. Returns an error if too many results match.
55	pub async fn query_transfers_by_prefix(
56		&self,
57		to_prefixes: Option<Vec<String>>,
58		from_prefixes: Option<Vec<String>>,
59		params: TransferQueryParams,
60	) -> Result<Vec<Transfer>> {
61		// Build the GraphQL query
62		let query = r#"
63            query TransfersByHashPrefix($input: TransfersByPrefixInput!) {
64                transfersByHashPrefix(input: $input) {
65                    transfers {
66                        id
67                        blockId
68                        blockHeight
69                        timestamp
70                        extrinsicHash
71                        fromId
72                        toId
73                        amount
74                        fee
75                        fromHash
76                        toHash
77                    }
78                    totalCount
79                }
80            }
81        "#;
82
83		// Build input variables
84		let mut input = serde_json::json!({
85			"limit": params.limit,
86			"offset": params.offset,
87		});
88
89		if let Some(prefixes) = to_prefixes {
90			input["toHashPrefixes"] = serde_json::json!(prefixes);
91		}
92
93		if let Some(prefixes) = from_prefixes {
94			input["fromHashPrefixes"] = serde_json::json!(prefixes);
95		}
96
97		if let Some(block) = params.after_block {
98			input["afterBlock"] = serde_json::json!(block);
99		}
100
101		if let Some(block) = params.before_block {
102			input["beforeBlock"] = serde_json::json!(block);
103		}
104
105		if let Some(amount) = params.min_amount {
106			input["minAmount"] = serde_json::json!(amount.to_string());
107		}
108
109		if let Some(amount) = params.max_amount {
110			input["maxAmount"] = serde_json::json!(amount.to_string());
111		}
112
113		let request = GraphQLRequest {
114			query: query.to_string(),
115			variables: serde_json::json!({ "input": input }),
116		};
117
118		// Send request
119		let response = self
120			.http_client
121			.post(&self.url)
122			.json(&request)
123			.send()
124			.await
125			.map_err(|e| QuantusError::Generic(format!("Failed to send request: {}", e)))?;
126
127		if !response.status().is_success() {
128			let status = response.status();
129			let body = response.text().await.unwrap_or_default();
130			return Err(QuantusError::Generic(format!(
131				"Subsquid request failed with status {}: {}",
132				status, body
133			)));
134		}
135
136		let graphql_response: GraphQLResponse<TransfersByHashPrefixData> = response
137			.json()
138			.await
139			.map_err(|e| QuantusError::Generic(format!("Failed to parse response: {}", e)))?;
140
141		// Check for GraphQL errors
142		if let Some(errors) = graphql_response.errors {
143			let error_messages: Vec<String> = errors.iter().map(|e| e.message.clone()).collect();
144			return Err(QuantusError::Generic(format!(
145				"GraphQL errors: {}",
146				error_messages.join("; ")
147			)));
148		}
149
150		// Extract transfers
151		let data = graphql_response
152			.data
153			.ok_or_else(|| QuantusError::Generic("No data in response".to_string()))?;
154
155		Ok(data.transfers_by_hash_prefix.transfers)
156	}
157
158	/// Query transfers for a set of addresses using privacy-preserving hash prefixes.
159	///
160	/// This is a convenience method that:
161	/// 1. Computes hash prefixes for all provided addresses
162	/// 2. Queries the indexer with those prefixes
163	/// 3. Filters results locally to only include transfers involving the exact addresses
164	///
165	/// # Arguments
166	///
167	/// * `addresses` - Raw 32-byte account IDs to query for
168	/// * `prefix_len` - Length of hash prefix to use (shorter = more privacy, more noise)
169	/// * `params` - Additional query parameters
170	///
171	/// # Returns
172	///
173	/// Transfers involving any of the provided addresses (filtered locally for exact matches)
174	pub async fn query_transfers_for_addresses(
175		&self,
176		addresses: &[[u8; 32]],
177		prefix_len: usize,
178		params: TransferQueryParams,
179	) -> Result<Vec<Transfer>> {
180		use super::hash::{compute_address_hash, get_hash_prefix};
181		use std::collections::HashSet;
182
183		if addresses.is_empty() {
184			return Ok(vec![]);
185		}
186
187		// Compute full hashes and prefixes for all addresses
188		let address_hashes: HashSet<String> = addresses.iter().map(compute_address_hash).collect();
189
190		let prefixes: Vec<String> = address_hashes
191			.iter()
192			.map(|h| get_hash_prefix(h, prefix_len))
193			.collect::<HashSet<_>>()
194			.into_iter()
195			.collect();
196
197		// Query with prefixes (for both to and from)
198		let transfers = self
199			.query_transfers_by_prefix(Some(prefixes.clone()), Some(prefixes), params)
200			.await?;
201
202		// Filter locally to only include exact matches
203		let filtered: Vec<Transfer> = transfers
204			.into_iter()
205			.filter(|t| {
206				address_hashes.contains(&t.to_hash) || address_hashes.contains(&t.from_hash)
207			})
208			.collect();
209
210		Ok(filtered)
211	}
212}
213
214#[cfg(test)]
215mod tests {
216	use super::*;
217
218	#[test]
219	fn test_transfer_query_params_builder() {
220		let params = TransferQueryParams::new()
221			.with_limit(50)
222			.with_offset(10)
223			.with_after_block(1000)
224			.with_before_block(2000);
225
226		assert_eq!(params.limit, 50);
227		assert_eq!(params.offset, 10);
228		assert_eq!(params.after_block, Some(1000));
229		assert_eq!(params.before_block, Some(2000));
230	}
231}