diff --git a/Cargo.lock b/Cargo.lock index 6191415..3ae10f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -784,6 +784,20 @@ dependencies = [ "constant_time_eq 0.3.1", ] +[[package]] +name = "blake3" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +dependencies = [ + "arrayref", + "arrayvec 0.7.6", + "cc", + "cfg-if", + "constant_time_eq 0.4.2", + "cpufeatures", +] + [[package]] name = "block-buffer" version = "0.9.0" @@ -891,6 +905,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.9.1" @@ -1077,6 +1097,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.7.1" @@ -1930,8 +1956,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1941,9 +1969,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasi 0.14.5+wasi-0.2.4", + "wasm-bindgen", ] [[package]] @@ -2238,6 +2268,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -2246,6 +2277,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" dependencies = [ + "base64", "bytes", "futures-channel", "futures-core", @@ -2253,7 +2285,9 @@ dependencies = [ "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", "tokio", @@ -2509,6 +2543,22 @@ dependencies = [ "libc", ] +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -2673,7 +2723,7 @@ dependencies = [ "serde_json", "thiserror 1.0.69", "tokio", - "tower", + "tower 0.4.13", "tracing", "url", ] @@ -2861,6 +2911,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "macro_magic" version = "0.5.1" @@ -3738,6 +3794,7 @@ version = "0.3.1" dependencies = [ "aes-gcm", "argon2", + "blake3", "chrono", "clap", "colored", @@ -3752,6 +3809,7 @@ dependencies = [ "qp-rusty-crystals-dilithium", "qp-rusty-crystals-hdwallet", "rand 0.9.2", + "reqwest", "rpassword", "serde", "serde_json", @@ -3766,6 +3824,61 @@ dependencies = [ "toml 0.9.5", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.16", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.16", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.40" @@ -3915,6 +4028,44 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower 0.5.3", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -4033,6 +4184,7 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ + "web-time", "zeroize", ] @@ -4417,6 +4569,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serdect" version = "0.2.0" @@ -5404,6 +5568,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -5717,6 +5890,39 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.9.4", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -6233,6 +6439,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index b4ec172..5fc0e82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,10 @@ qp-rusty-crystals-hdwallet = { version = "1.0.0" } qp-poseidon = { version = "1.0.1", default-features = false } qp-dilithium-crypto = { version = "0.2.0", features = ["serde"] } +# HTTP client for Subsquid queries +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +blake3 = "1.8" + # Blockchain and RPC client codec = { package = "parity-scale-codec", version = "3.7", features = [ "derive", diff --git a/src/cli/mod.rs b/src/cli/mod.rs index a5c768e..ccceb19 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -22,6 +22,7 @@ pub mod storage; pub mod system; pub mod tech_collective; pub mod tech_referenda; +pub mod transfers; pub mod treasury; pub mod wallet; @@ -105,6 +106,10 @@ pub enum Commands { #[command(subcommand)] Treasury(treasury::TreasuryCommands), + /// Privacy-preserving transfer queries via Subsquid indexer + #[command(subcommand)] + Transfers(transfers::TransfersCommands), + /// Runtime management commands (requires root/sudo permissions) #[command(subcommand)] Runtime(runtime::RuntimeCommands), @@ -293,6 +298,8 @@ pub async fn execute_command( referenda::handle_referenda_command(referenda_cmd, node_url, execution_mode).await, Commands::Treasury(treasury_cmd) => treasury::handle_treasury_command(treasury_cmd, node_url, execution_mode).await, + Commands::Transfers(transfers_cmd) => + transfers::handle_transfers_command(transfers_cmd).await, Commands::Runtime(runtime_cmd) => runtime::handle_runtime_command(runtime_cmd, node_url, execution_mode).await, Commands::Call { diff --git a/src/cli/transfers.rs b/src/cli/transfers.rs new file mode 100644 index 0000000..1275d4b --- /dev/null +++ b/src/cli/transfers.rs @@ -0,0 +1,263 @@ +//! Privacy-preserving transfer queries via Subsquid indexer. +//! +//! This module provides commands for querying transfers using hash prefix queries, +//! which allows clients to retrieve their transactions without revealing their +//! exact addresses to the indexer. + +use crate::{ + cli::send::format_balance, + error::{QuantusError, Result}, + log_error, log_print, log_success, log_verbose, + subsquid::{compute_address_hash, get_hash_prefix, SubsquidClient, TransferQueryParams}, + wallet::WalletManager, +}; +use clap::Subcommand; +use colored::Colorize; +use sp_core::crypto::{AccountId32, Ss58Codec}; + +/// Transfers subcommands +#[derive(Subcommand, Debug)] +pub enum TransfersCommands { + /// Query transfers for your wallet addresses using privacy-preserving hash prefix queries + Query { + /// Subsquid indexer URL (e.g., "https://indexer.quantus.com/graphql") + #[arg(long)] + subsquid_url: String, + + /// Hash prefix length in hex characters (1-64). + /// Shorter = more privacy but more noise, longer = less privacy but fewer false positives. + /// Default: 4 (1/65536 of address space per prefix) + #[arg(long, default_value = "4")] + prefix_len: usize, + + /// Only show transfers after this block number + #[arg(long)] + after_block: Option, + + /// Only show transfers before this block number + #[arg(long)] + before_block: Option, + + /// Minimum transfer amount (in smallest unit, e.g., planck) + #[arg(long)] + min_amount: Option, + + /// Maximum number of results (default: 100, max: 1000) + #[arg(long, default_value = "100")] + limit: u32, + + /// Specific wallet name to query for (if not provided, queries all wallets) + #[arg(long)] + wallet: Option, + + /// Show raw transfer data as JSON + #[arg(long)] + json: bool, + }, + + /// Compute the hash prefix for an address (for debugging/testing) + HashAddress { + /// The address to hash (SS58 format) + address: String, + + /// Prefix length to display + #[arg(long, default_value = "4")] + prefix_len: usize, + }, +} + +/// Handle transfers commands +pub async fn handle_transfers_command(cmd: TransfersCommands) -> Result<()> { + match cmd { + TransfersCommands::Query { + subsquid_url, + prefix_len, + after_block, + before_block, + min_amount, + limit, + wallet, + json, + } => + handle_query_command( + subsquid_url, + prefix_len, + after_block, + before_block, + min_amount, + limit, + wallet, + json, + ) + .await, + TransfersCommands::HashAddress { address, prefix_len } => + handle_hash_address_command(&address, prefix_len), + } +} + +/// Handle the query subcommand +#[allow(clippy::too_many_arguments)] +async fn handle_query_command( + subsquid_url: String, + prefix_len: usize, + after_block: Option, + before_block: Option, + min_amount: Option, + limit: u32, + wallet_name: Option, + json_output: bool, +) -> Result<()> { + // Validate prefix length + if prefix_len == 0 || prefix_len > 64 { + return Err(QuantusError::Generic("Prefix length must be between 1 and 64".to_string())); + } + + // Load wallet addresses + let wallet_manager = WalletManager::new()?; + let wallets = wallet_manager.list_wallets()?; + + if wallets.is_empty() { + log_error!("No wallets found. Create a wallet first with 'quantus wallet create'"); + return Ok(()); + } + + // Filter to specific wallet if requested + let wallets_to_query: Vec<_> = if let Some(name) = &wallet_name { + wallets.into_iter().filter(|w| w.name == *name).collect() + } else { + wallets + }; + + if wallets_to_query.is_empty() { + log_error!("No matching wallet found"); + return Ok(()); + } + + // Convert SS58 addresses to raw account IDs + let mut raw_addresses: Vec<[u8; 32]> = Vec::new(); + for wallet in &wallets_to_query { + let account_id = AccountId32::from_ss58check(&wallet.address).map_err(|e| { + QuantusError::Generic(format!("Invalid address {}: {}", wallet.address, e)) + })?; + raw_addresses.push(account_id.into()); + } + + if !json_output { + log_print!("{}", "Privacy-Preserving Transfer Query".bright_cyan().bold()); + log_print!(""); + log_print!( + " Querying for {} wallet(s) with prefix length {}", + wallets_to_query.len().to_string().bright_yellow(), + prefix_len.to_string().bright_yellow() + ); + log_print!( + " Privacy level: ~1/{} of address space per query", + (1u64 << (prefix_len * 4)).to_string().bright_green() + ); + log_print!(""); + } + + // Create Subsquid client + let client = SubsquidClient::new(subsquid_url)?; + + // Build query params + let mut params = TransferQueryParams::new().with_limit(limit); + if let Some(block) = after_block { + params = params.with_after_block(block); + } + if let Some(block) = before_block { + params = params.with_before_block(block); + } + if let Some(amount) = min_amount { + params = params.with_min_amount(amount); + } + + // Query transfers + let transfers = + client.query_transfers_for_addresses(&raw_addresses, prefix_len, params).await?; + + if json_output { + // Output as JSON + let json = serde_json::to_string_pretty(&transfers) + .map_err(|e| QuantusError::Generic(format!("Failed to serialize transfers: {}", e)))?; + println!("{}", json); + } else { + // Display formatted output + if transfers.is_empty() { + log_print!("No transfers found for your addresses."); + } else { + log_success!("Found {} transfers:", transfers.len().to_string().bright_green()); + log_print!(""); + + for transfer in &transfers { + // Determine if this is incoming or outgoing + let our_address_hashes: std::collections::HashSet = + raw_addresses.iter().map(compute_address_hash).collect(); + + let is_incoming = our_address_hashes.contains(&transfer.to_hash); + let is_outgoing = our_address_hashes.contains(&transfer.from_hash); + + let direction = match (is_incoming, is_outgoing) { + (true, true) => "SELF".bright_blue(), + (true, false) => "IN".bright_green(), + (false, true) => "OUT".bright_red(), + (false, false) => "???".dimmed(), // Shouldn't happen + }; + + // Parse and format amount (12 decimals is standard for Substrate) + let amount: u128 = transfer.amount.parse().unwrap_or(0); + let formatted_amount = format!("{} DEV", format_balance(amount, 12)); + + log_print!( + " [{}] {} | Block {} | {} | {} -> {}", + direction, + &transfer.timestamp[..19], // Truncate to YYYY-MM-DDTHH:MM:SS + transfer.block_height.to_string().bright_yellow(), + formatted_amount.bright_cyan(), + truncate_address(&transfer.from_id), + truncate_address(&transfer.to_id), + ); + + if let Some(hash) = &transfer.extrinsic_hash { + log_verbose!(" Extrinsic: {}", hash.dimmed()); + } + } + } + } + + Ok(()) +} + +/// Handle the hash-address subcommand +fn handle_hash_address_command(address: &str, prefix_len: usize) -> Result<()> { + // Parse the SS58 address + let account_id = AccountId32::from_ss58check(address) + .map_err(|e| QuantusError::Generic(format!("Invalid address: {}", e)))?; + + let raw_address: [u8; 32] = account_id.into(); + let full_hash = compute_address_hash(&raw_address); + let prefix = get_hash_prefix(&full_hash, prefix_len); + + log_print!("{}", "Address Hash Information".bright_cyan().bold()); + log_print!(""); + log_print!(" Address: {}", address.bright_yellow()); + log_print!(" Full Hash: {}", full_hash.dimmed()); + log_print!(" Prefix ({}): {}", prefix_len, prefix.bright_green().bold()); + log_print!(""); + log_print!( + " Privacy: With prefix length {}, your query will match ~1/{} of all addresses", + prefix_len, + (1u64 << (prefix_len * 4)).to_string().bright_cyan() + ); + + Ok(()) +} + +/// Truncate an address for display +fn truncate_address(address: &str) -> String { + if address.len() > 16 { + format!("{}...{}", &address[..8], &address[address.len() - 6..]) + } else { + address.to_string() + } +} diff --git a/src/lib.rs b/src/lib.rs index 95389b8..01a73ed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod cli; pub mod config; pub mod error; pub mod log; +pub mod subsquid; pub mod wallet; // Re-export commonly used types and functions diff --git a/src/main.rs b/src/main.rs index 2bf667a..684b112 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ mod cli; mod config; mod error; mod log; +mod subsquid; mod wallet; use cli::Commands; diff --git a/src/subsquid/client.rs b/src/subsquid/client.rs new file mode 100644 index 0000000..d0ef368 --- /dev/null +++ b/src/subsquid/client.rs @@ -0,0 +1,231 @@ +//! Subsquid GraphQL client for privacy-preserving transfer queries. + +use crate::error::{QuantusError, Result}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; + +use super::types::{GraphQLResponse, Transfer, TransferQueryParams, TransfersByPrefixResult}; + +/// Client for querying the Subsquid indexer. +pub struct SubsquidClient { + url: String, + http_client: Client, +} + +#[derive(Serialize)] +struct GraphQLRequest { + query: String, + variables: serde_json::Value, +} + +#[derive(Deserialize)] +struct TransfersByHashPrefixData { + #[serde(rename = "transfersByHashPrefix")] + transfers_by_hash_prefix: TransfersByPrefixResult, +} + +impl SubsquidClient { + /// Create a new Subsquid client. + /// + /// # Arguments + /// + /// * `url` - The GraphQL endpoint URL (e.g., "https://indexer.quantus.com/graphql") + pub fn new(url: String) -> Result { + let http_client = Client::builder() + .build() + .map_err(|e| QuantusError::Generic(format!("Failed to create HTTP client: {}", e)))?; + + Ok(Self { url, http_client }) + } + + /// Query transfers by hash prefixes. + /// + /// This method allows privacy-preserving queries by matching against + /// blake3 hash prefixes of addresses rather than the addresses themselves. + /// + /// # Arguments + /// + /// * `to_prefixes` - Hash prefixes for destination addresses (OR logic) + /// * `from_prefixes` - Hash prefixes for source addresses (OR logic) + /// * `params` - Additional query parameters (block range, amount filters, pagination) + /// + /// # Returns + /// + /// A list of matching transfers. Returns an error if too many results match. + pub async fn query_transfers_by_prefix( + &self, + to_prefixes: Option>, + from_prefixes: Option>, + params: TransferQueryParams, + ) -> Result> { + // Build the GraphQL query + let query = r#" + query TransfersByHashPrefix($input: TransfersByPrefixInput!) { + transfersByHashPrefix(input: $input) { + transfers { + id + blockId + blockHeight + timestamp + extrinsicHash + fromId + toId + amount + fee + fromHash + toHash + } + totalCount + } + } + "#; + + // Build input variables + let mut input = serde_json::json!({ + "limit": params.limit, + "offset": params.offset, + }); + + if let Some(prefixes) = to_prefixes { + input["toHashPrefixes"] = serde_json::json!(prefixes); + } + + if let Some(prefixes) = from_prefixes { + input["fromHashPrefixes"] = serde_json::json!(prefixes); + } + + if let Some(block) = params.after_block { + input["afterBlock"] = serde_json::json!(block); + } + + if let Some(block) = params.before_block { + input["beforeBlock"] = serde_json::json!(block); + } + + if let Some(amount) = params.min_amount { + input["minAmount"] = serde_json::json!(amount.to_string()); + } + + if let Some(amount) = params.max_amount { + input["maxAmount"] = serde_json::json!(amount.to_string()); + } + + let request = GraphQLRequest { + query: query.to_string(), + variables: serde_json::json!({ "input": input }), + }; + + // Send request + let response = self + .http_client + .post(&self.url) + .json(&request) + .send() + .await + .map_err(|e| QuantusError::Generic(format!("Failed to send request: {}", e)))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(QuantusError::Generic(format!( + "Subsquid request failed with status {}: {}", + status, body + ))); + } + + let graphql_response: GraphQLResponse = response + .json() + .await + .map_err(|e| QuantusError::Generic(format!("Failed to parse response: {}", e)))?; + + // Check for GraphQL errors + if let Some(errors) = graphql_response.errors { + let error_messages: Vec = errors.iter().map(|e| e.message.clone()).collect(); + return Err(QuantusError::Generic(format!( + "GraphQL errors: {}", + error_messages.join("; ") + ))); + } + + // Extract transfers + let data = graphql_response + .data + .ok_or_else(|| QuantusError::Generic("No data in response".to_string()))?; + + Ok(data.transfers_by_hash_prefix.transfers) + } + + /// Query transfers for a set of addresses using privacy-preserving hash prefixes. + /// + /// This is a convenience method that: + /// 1. Computes hash prefixes for all provided addresses + /// 2. Queries the indexer with those prefixes + /// 3. Filters results locally to only include transfers involving the exact addresses + /// + /// # Arguments + /// + /// * `addresses` - Raw 32-byte account IDs to query for + /// * `prefix_len` - Length of hash prefix to use (shorter = more privacy, more noise) + /// * `params` - Additional query parameters + /// + /// # Returns + /// + /// Transfers involving any of the provided addresses (filtered locally for exact matches) + pub async fn query_transfers_for_addresses( + &self, + addresses: &[[u8; 32]], + prefix_len: usize, + params: TransferQueryParams, + ) -> Result> { + use super::hash::{compute_address_hash, get_hash_prefix}; + use std::collections::HashSet; + + if addresses.is_empty() { + return Ok(vec![]); + } + + // Compute full hashes and prefixes for all addresses + let address_hashes: HashSet = addresses.iter().map(compute_address_hash).collect(); + + let prefixes: Vec = address_hashes + .iter() + .map(|h| get_hash_prefix(h, prefix_len)) + .collect::>() + .into_iter() + .collect(); + + // Query with prefixes (for both to and from) + let transfers = self + .query_transfers_by_prefix(Some(prefixes.clone()), Some(prefixes), params) + .await?; + + // Filter locally to only include exact matches + let filtered: Vec = transfers + .into_iter() + .filter(|t| { + address_hashes.contains(&t.to_hash) || address_hashes.contains(&t.from_hash) + }) + .collect(); + + Ok(filtered) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_transfer_query_params_builder() { + let params = TransferQueryParams::new() + .with_limit(50) + .with_offset(10) + .with_after_block(1000) + .with_before_block(2000); + + assert_eq!(params.limit, 50); + assert_eq!(params.offset, 10); + assert_eq!(params.after_block, Some(1000)); + assert_eq!(params.before_block, Some(2000)); + } +} diff --git a/src/subsquid/hash.rs b/src/subsquid/hash.rs new file mode 100644 index 0000000..349687c --- /dev/null +++ b/src/subsquid/hash.rs @@ -0,0 +1,86 @@ +//! Hash utilities for privacy-preserving queries. +//! +//! Uses blake3 to compute address hashes that match the Subsquid indexer. + +/// Compute blake3 hash of raw address bytes and return as hex string. +/// +/// This matches the hash computation done by the Subsquid indexer. +/// +/// # Arguments +/// +/// * `raw_address` - The raw 32-byte account ID +/// +/// # Returns +/// +/// The blake3 hash as a 64-character hex string +pub fn compute_address_hash(raw_address: &[u8; 32]) -> String { + let hash = blake3::hash(raw_address); + hex::encode(hash.as_bytes()) +} + +/// Get a prefix of the specified length from a hash. +/// +/// # Arguments +/// +/// * `hash` - The full hash as a hex string +/// * `prefix_len` - The number of hex characters to include in the prefix +/// +/// # Returns +/// +/// The prefix as a hex string +pub fn get_hash_prefix(hash: &str, prefix_len: usize) -> String { + hash.chars().take(prefix_len).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + // Known test vectors - these values are verified against the TypeScript implementation + const ZERO_BYTES_HASH: &str = + "2ada83c1819a5372dae1238fc1ded123c8104fdaa15862aaee69428a1820fcda"; + const ONES_BYTES_HASH: &str = + "9b34f060fbc0f0aa11f150e26519deff613277b60656f0f8356ed2261505f5c5"; + const SEQUENTIAL_BYTES_HASH: &str = + "e528e95798037df410543d9f31e396ecdd458d71b157d6014398bae32fb56c65"; + + #[test] + fn test_known_hash_vectors() { + // These test vectors ensure Rust and TypeScript produce identical hashes + assert_eq!(compute_address_hash(&[0u8; 32]), ZERO_BYTES_HASH); + assert_eq!(compute_address_hash(&[0xffu8; 32]), ONES_BYTES_HASH); + + let mut sequential = [0u8; 32]; + for (i, byte) in sequential.iter_mut().enumerate() { + *byte = i as u8; + } + assert_eq!(compute_address_hash(&sequential), SEQUENTIAL_BYTES_HASH); + } + + #[test] + fn test_hash_format() { + let hash = compute_address_hash(&[0u8; 32]); + assert_eq!(hash.len(), 64); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_hash_determinism() { + let address = [42u8; 32]; + assert_eq!(compute_address_hash(&address), compute_address_hash(&address)); + } + + #[test] + fn test_different_inputs_different_hashes() { + assert_ne!(compute_address_hash(&[1u8; 32]), compute_address_hash(&[2u8; 32])); + } + + #[test] + fn test_get_hash_prefix() { + let hash = "abcdef1234567890"; + assert_eq!(get_hash_prefix(hash, 0), ""); + assert_eq!(get_hash_prefix(hash, 2), "ab"); + assert_eq!(get_hash_prefix(hash, 4), "abcd"); + assert_eq!(get_hash_prefix(hash, 100), hash); // longer than input returns full string + } +} diff --git a/src/subsquid/mod.rs b/src/subsquid/mod.rs new file mode 100644 index 0000000..1ad91ea --- /dev/null +++ b/src/subsquid/mod.rs @@ -0,0 +1,13 @@ +//! Subsquid indexer client module for privacy-preserving queries. +//! +//! This module provides a client for querying the Subsquid indexer using +//! hash prefix queries, which allows clients to retrieve their transactions +//! without revealing their exact addresses to the indexer. + +mod client; +mod hash; +mod types; + +pub use client::SubsquidClient; +pub use hash::{compute_address_hash, get_hash_prefix}; +pub use types::*; diff --git a/src/subsquid/types.rs b/src/subsquid/types.rs new file mode 100644 index 0000000..00aba1f --- /dev/null +++ b/src/subsquid/types.rs @@ -0,0 +1,265 @@ +//! Types for Subsquid API responses. + +use serde::{Deserialize, Serialize}; + +/// A transfer as returned by the Subsquid indexer. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Transfer { + /// Unique identifier + pub id: String, + + /// Block ID + pub block_id: String, + + /// Block height + pub block_height: i64, + + /// Timestamp of the transfer + pub timestamp: String, + + /// Extrinsic hash (if available) + pub extrinsic_hash: Option, + + /// Sender address (SS58 format) + pub from_id: String, + + /// Recipient address (SS58 format) + pub to_id: String, + + /// Transfer amount (as string to handle large numbers) + pub amount: String, + + /// Transaction fee + pub fee: String, + + /// Blake3 hash of the sender's raw address + pub from_hash: String, + + /// Blake3 hash of the recipient's raw address + pub to_hash: String, +} + +/// Result from a prefix query. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TransfersByPrefixResult { + /// Matching transfers + pub transfers: Vec, + + /// Total count of matches + pub total_count: i64, +} + +/// GraphQL response wrapper. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphQLResponse { + pub data: Option, + pub errors: Option>, +} + +/// GraphQL error. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphQLError { + pub message: String, + pub locations: Option>, + pub path: Option>, +} + +/// GraphQL error location. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphQLErrorLocation { + pub line: i64, + pub column: i64, +} + +/// Query parameters for transfer prefix queries. +#[derive(Debug, Clone, Default)] +pub struct TransferQueryParams { + /// Minimum block number (inclusive) + pub after_block: Option, + + /// Maximum block number (inclusive) + pub before_block: Option, + + /// Minimum transfer amount + pub min_amount: Option, + + /// Maximum transfer amount + pub max_amount: Option, + + /// Maximum number of results + pub limit: u32, + + /// Offset for pagination + pub offset: u32, +} + +impl TransferQueryParams { + pub fn new() -> Self { + Self { limit: 100, offset: 0, ..Default::default() } + } + + pub fn with_limit(mut self, limit: u32) -> Self { + self.limit = limit; + self + } + + #[allow(dead_code)] + pub fn with_offset(mut self, offset: u32) -> Self { + self.offset = offset; + self + } + + pub fn with_after_block(mut self, block: u32) -> Self { + self.after_block = Some(block); + self + } + + pub fn with_before_block(mut self, block: u32) -> Self { + self.before_block = Some(block); + self + } + + pub fn with_min_amount(mut self, amount: u128) -> Self { + self.min_amount = Some(amount); + self + } + + #[allow(dead_code)] + pub fn with_max_amount(mut self, amount: u128) -> Self { + self.max_amount = Some(amount); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_transfer_query_params_default() { + let params = TransferQueryParams::default(); + assert_eq!(params.limit, 0); + assert_eq!(params.offset, 0); + assert!(params.after_block.is_none()); + assert!(params.before_block.is_none()); + assert!(params.min_amount.is_none()); + assert!(params.max_amount.is_none()); + } + + #[test] + fn test_transfer_query_params_new() { + let params = TransferQueryParams::new(); + assert_eq!(params.limit, 100); + assert_eq!(params.offset, 0); + } + + #[test] + fn test_transfer_query_params_builder() { + let params = TransferQueryParams::new() + .with_limit(50) + .with_offset(10) + .with_after_block(1000) + .with_before_block(2000) + .with_min_amount(1_000_000) + .with_max_amount(10_000_000); + + assert_eq!(params.limit, 50); + assert_eq!(params.offset, 10); + assert_eq!(params.after_block, Some(1000)); + assert_eq!(params.before_block, Some(2000)); + assert_eq!(params.min_amount, Some(1_000_000)); + assert_eq!(params.max_amount, Some(10_000_000)); + } + + #[test] + fn test_transfer_deserialization() { + let json = r#"{ + "id": "transfer-123", + "blockId": "block-456", + "blockHeight": 12345, + "timestamp": "2024-01-15T12:30:00Z", + "extrinsicHash": "0xabcd1234", + "fromId": "qzAlice123", + "toId": "qzBob456", + "amount": "1000000000000", + "fee": "1000000", + "fromHash": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + "toHash": "5678efgh5678efgh5678efgh5678efgh5678efgh5678efgh5678efgh5678efgh" + }"#; + + let transfer: Transfer = serde_json::from_str(json).expect("should deserialize"); + + assert_eq!(transfer.id, "transfer-123"); + assert_eq!(transfer.block_id, "block-456"); + assert_eq!(transfer.block_height, 12345); + assert_eq!(transfer.timestamp, "2024-01-15T12:30:00Z"); + assert_eq!(transfer.extrinsic_hash, Some("0xabcd1234".to_string())); + assert_eq!(transfer.from_id, "qzAlice123"); + assert_eq!(transfer.to_id, "qzBob456"); + assert_eq!(transfer.amount, "1000000000000"); + assert_eq!(transfer.fee, "1000000"); + } + + #[test] + fn test_transfer_deserialization_null_extrinsic_hash() { + let json = r#"{ + "id": "transfer-123", + "blockId": "block-456", + "blockHeight": 12345, + "timestamp": "2024-01-15T12:30:00Z", + "extrinsicHash": null, + "fromId": "qzAlice123", + "toId": "qzBob456", + "amount": "1000000000000", + "fee": "1000000", + "fromHash": "abcd1234", + "toHash": "5678efgh" + }"#; + + let transfer: Transfer = serde_json::from_str(json).expect("should deserialize"); + assert!(transfer.extrinsic_hash.is_none()); + } + + #[test] + fn test_graphql_response_with_data() { + let json = r#"{ + "data": { + "transfers": [], + "totalCount": 0 + } + }"#; + + let response: GraphQLResponse = + serde_json::from_str(json).expect("should deserialize"); + + assert!(response.data.is_some()); + assert!(response.errors.is_none()); + assert_eq!(response.data.unwrap().total_count, 0); + } + + #[test] + fn test_graphql_response_with_error() { + let json = r#"{ + "data": null, + "errors": [ + { + "message": "Query returned too many results", + "locations": [{"line": 1, "column": 1}], + "path": ["transfersByHashPrefix"] + } + ] + }"#; + + let response: GraphQLResponse = + serde_json::from_str(json).expect("should deserialize"); + + assert!(response.data.is_none()); + assert!(response.errors.is_some()); + + let errors = response.errors.unwrap(); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].message, "Query returned too many results"); + } +}