From 56473a26c5b4e2791baf3696ace96e6890dcf928 Mon Sep 17 00:00:00 2001 From: Beast Date: Fri, 12 Jun 2026 13:42:41 +0800 Subject: [PATCH 1/2] feat: update subsquid client to use latest subsquid --- README.md | 4 +- src/cli/transfers.rs | 2 +- src/cli/wormhole.rs | 4 +- src/subsquid/client.rs | 265 ++++++++++++++++++++++++----------------- src/subsquid/types.rs | 231 ++++++++++++++++++++++++++++++----- 5 files changed, 361 insertions(+), 145 deletions(-) diff --git a/README.md b/README.md index 6589240..a6f35ac 100644 --- a/README.md +++ b/README.md @@ -264,7 +264,7 @@ quantus wormhole collect-rewards --wallet my_wallet --dry-run - `--destination`: Destination address for withdrawn funds (required with `--mnemonic`, defaults to wallet address). - `--amount`: Amount in DEV to withdraw (default: withdraw all available). - `--wormhole-index`: Wormhole address index for HD derivation (default: `0`). -- `--subsquid-url`: Subsquid indexer URL (default: `https://subsquid.quantus.com/blue/graphql`). +- `--subsquid-url`: Subsquid indexer URL (default: `https://sub2.quantus.com/v1/graphql`). - `--dry-run`: Show available transfers without submitting any transactions. - `--at-block`: Use a specific block number for proofs instead of the latest block. @@ -287,7 +287,7 @@ quantus wormhole check-nullifier --secret 0x<64-hex-chars> --transfer-counts 0-5 - `--secret`: 32-byte hex secret (alternative to `--wallet`). - `--transfer-counts`: Single number or range (e.g., `0-10`) of transfer counts to check. - `--wormhole-index`: Wormhole address index for HD derivation (default: `0`). -- `--subsquid-url`: Subsquid indexer URL (default: `https://subsquid.quantus.com/blue/graphql`). +- `--subsquid-url`: Subsquid indexer URL (default: `https://sub2.quantus.com/v1/graphql`). --- diff --git a/src/cli/transfers.rs b/src/cli/transfers.rs index fe659b6..ca0ef6c 100644 --- a/src/cli/transfers.rs +++ b/src/cli/transfers.rs @@ -21,7 +21,7 @@ pub enum TransfersCommands { /// Query transfers for your wallet addresses using privacy-preserving hash prefix queries Query { /// Subsquid indexer URL - #[arg(long, default_value = "https://subsquid.quantus.com/blue/graphql")] + #[arg(long, default_value = "https://sub2.quantus.com/v1/graphql")] subsquid_url: String, /// Hash prefix length in hex characters (1-64). diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index 6a8d724..b49bfb0 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -839,7 +839,7 @@ pub enum WormholeCommands { destination: Option, /// Subsquid indexer URL for querying transfers - #[arg(long, default_value = "https://subsquid.quantus.com/blue/graphql")] + #[arg(long, default_value = "https://sub2.quantus.com/v1/graphql")] subsquid_url: String, /// Wormhole address index for HD derivation (default: 0, ignored when using --secret) @@ -886,7 +886,7 @@ pub enum WormholeCommands { transfer_counts: String, /// Subsquid indexer URL for querying nullifiers - #[arg(long, default_value = "https://subsquid.quantus.com/blue/graphql")] + #[arg(long, default_value = "https://sub2.quantus.com/v1/graphql")] subsquid_url: String, }, } diff --git a/src/subsquid/client.rs b/src/subsquid/client.rs index 45b9b2f..7655a24 100644 --- a/src/subsquid/client.rs +++ b/src/subsquid/client.rs @@ -1,15 +1,20 @@ -//! Subsquid GraphQL client for privacy-preserving transfer queries. +//! Hasura GraphQL client for privacy-preserving transfer queries. use crate::error::{QuantusError, Result}; use reqwest::Client; use serde::{Deserialize, Serialize}; use super::types::{ - GraphQLResponse, NullifierQueryParams, NullifierResult, NullifiersByPrefixResult, Transfer, - TransferQueryParams, TransfersByPrefixResult, + GraphQLResponse, HasuraNullifierRow, HasuraTransferRow, NullifierQueryParams, NullifierResult, + Transfer, TransferQueryParams, }; -/// Client for querying the Subsquid indexer. +/// Maximum number of results the client will accept for a single broad query. +/// Mirrors the cap the old custom GraphQL server enforced; queries matching +/// more rows than this are rejected so callers can narrow their block range. +const SERVER_MAX_LIMIT: u32 = 1000; + +/// Client for querying the Hasura GraphQL indexer. pub struct SubsquidClient { url: String, http_client: Client, @@ -22,15 +27,24 @@ struct GraphQLRequest { } #[derive(Deserialize)] -struct TransfersByHashPrefixData { - #[serde(rename = "transfersByHashPrefix")] - transfers_by_hash_prefix: TransfersByPrefixResult, +struct HasuraTransfersData { + transfers: Vec, + meta: AggregateWrapper, +} + +#[derive(Deserialize)] +struct AggregateWrapper { + aggregate: Option, } #[derive(Deserialize)] -struct NullifiersByPrefixData { - #[serde(rename = "nullifiersByPrefix")] - nullifiers_by_prefix: NullifiersByPrefixResult, +struct AggregateCount { + count: i64, +} + +#[derive(Deserialize)] +struct HasuraNullifiersData { + nullifiers: Vec, } impl SubsquidClient { @@ -67,70 +81,127 @@ impl SubsquidClient { from_prefixes: Option>, params: TransferQueryParams, ) -> Result> { - // Build the GraphQL query + // Hasura table query with an aggregate count so we can emulate the old + // server's "too many results" rejection for overly broad queries. let query = r#" - query TransfersByHashPrefix($input: TransfersByPrefixInput!) { - transfersByHashPrefix(input: $input) { - transfers { - id - blockId - blockHeight - timestamp - extrinsicHash - fromId - toId - amount - fee - fromHash - toHash - leafIndex - transferCount - } - totalCount + query TransfersByHashPrefix($where: transfer_bool_exp!, $limit: Int!, $offset: Int!) { + transfers: transfer( + where: $where + limit: $limit + offset: $offset + order_by: [{ block: { height: asc } }, { id: asc }] + ) { + id + block_id + block { height } + timestamp + extrinsic_id + from_id + to_id + amount + fee + from_hash + to_hash + leaf_index + transfer_count + } + meta: transfer_aggregate(where: $where) { + aggregate { count } } } "#; - // Build input variables - let mut input = serde_json::json!({ - "limit": params.limit, - "offset": params.offset, - }); + let where_clause = Self::build_transfer_where(&to_prefixes, &from_prefixes, ¶ms); - if let Some(prefixes) = to_prefixes { - input["toHashPrefixes"] = serde_json::json!(prefixes); + let request = GraphQLRequest { + query: query.to_string(), + variables: serde_json::json!({ + "where": where_clause, + "limit": params.limit, + "offset": params.offset, + }), + }; + + let data: HasuraTransfersData = self.execute(&request).await?; + + let total_count = data.meta.aggregate.map(|a| a.count).unwrap_or(0); + if total_count > SERVER_MAX_LIMIT as i64 { + // Same wording as the old server so query_all_transfers_by_prefix + // keeps binary-splitting block ranges on this marker. + return Err(QuantusError::Generic(format!( + "Query returned {} results, which exceeds the limit of {}. \ + Please use longer hash prefixes or a narrower block range for more specific queries.", + total_count, SERVER_MAX_LIMIT + ))); } + Ok(data.transfers.into_iter().map(Transfer::from).collect()) + } + + /// Build a Hasura `transfer_bool_exp` where-clause from prefix lists and params. + fn build_transfer_where( + to_prefixes: &Option>, + from_prefixes: &Option>, + params: &TransferQueryParams, + ) -> serde_json::Value { + let mut where_clause = serde_json::Map::new(); + + // Prefix conditions are OR'ed together (a transfer matches if any + // to/from hash prefix matches), then AND'ed with the other filters. + let mut or_conditions: Vec = Vec::new(); + if let Some(prefixes) = to_prefixes { + for prefix in prefixes { + or_conditions.push(serde_json::json!({ + "to_hash": { "_like": format!("{}%", prefix) } + })); + } + } if let Some(prefixes) = from_prefixes { - input["fromHashPrefixes"] = serde_json::json!(prefixes); + for prefix in prefixes { + or_conditions.push(serde_json::json!({ + "from_hash": { "_like": format!("{}%", prefix) } + })); + } + } + if !or_conditions.is_empty() { + where_clause.insert("_or".to_string(), serde_json::Value::Array(or_conditions)); } + let mut height = serde_json::Map::new(); if let Some(block) = params.after_block { - input["afterBlock"] = serde_json::json!(block); + height.insert("_gte".to_string(), serde_json::json!(block)); } - if let Some(block) = params.before_block { - input["beforeBlock"] = serde_json::json!(block); + height.insert("_lte".to_string(), serde_json::json!(block)); } - - if let Some(amount) = params.min_amount { - input["minAmount"] = serde_json::json!(amount.to_string()); + if !height.is_empty() { + where_clause.insert( + "block".to_string(), + serde_json::json!({ "height": serde_json::Value::Object(height) }), + ); } - if let Some(amount) = params.max_amount { - input["maxAmount"] = serde_json::json!(amount.to_string()); + // Amounts are sent as strings to avoid precision loss on large values. + let mut amount = serde_json::Map::new(); + if let Some(min) = params.min_amount { + amount.insert("_gte".to_string(), serde_json::json!(min.to_string())); + } + if let Some(max) = params.max_amount { + amount.insert("_lte".to_string(), serde_json::json!(max.to_string())); + } + if !amount.is_empty() { + where_clause.insert("amount".to_string(), serde_json::Value::Object(amount)); } - let request = GraphQLRequest { - query: query.to_string(), - variables: serde_json::json!({ "input": input }), - }; + serde_json::Value::Object(where_clause) + } - // Send request + /// Execute a GraphQL request and deserialize the `data` payload. + async fn execute(&self, request: &GraphQLRequest) -> Result { let response = self .http_client .post(&self.url) - .json(&request) + .json(request) .send() .await .map_err(|e| QuantusError::Generic(format!("Failed to send request: {}", e)))?; @@ -139,17 +210,16 @@ impl SubsquidClient { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(QuantusError::Generic(format!( - "Subsquid request failed with status {}: {}", + "Indexer request failed with status {}: {}", status, body ))); } - let graphql_response: GraphQLResponse = response + 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!( @@ -158,12 +228,9 @@ impl SubsquidClient { ))); } - // Extract transfers - let data = graphql_response + graphql_response .data - .ok_or_else(|| QuantusError::Generic("No data in response".to_string()))?; - - Ok(data.transfers_by_hash_prefix.transfers) + .ok_or_else(|| QuantusError::Generic("No data in response".to_string())) } /// Fetch every transfer matching the given prefixes, paginating by block range. @@ -183,7 +250,6 @@ impl SubsquidClient { from_prefixes: Option>, base_params: TransferQueryParams, ) -> Result> { - const SERVER_MAX_LIMIT: u32 = 1000; const LIMIT_EXCEEDED_MARKER: &str = "exceeds the limit"; const MAX_BLOCK_SENTINEL: u32 = i32::MAX as u32; @@ -305,68 +371,49 @@ impl SubsquidClient { } let query = r#" - query NullifiersByPrefix($input: NullifiersByPrefixInput!) { - nullifiersByPrefix(input: $input) { - nullifiers { - nullifier - nullifierHash - extrinsicHash - blockHeight - timestamp - } - totalCount + query NullifiersByPrefix($where: wormhole_nullifier_bool_exp!) { + nullifiers: wormhole_nullifier( + where: $where + order_by: [{ timestamp: asc }] + ) { + nullifier + nullifier_hash + block { height } + timestamp + wormholeExtrinsic { extrinsic_id } } } "#; - let mut input = serde_json::json!({ - "hashPrefixes": prefixes, - }); + let or_conditions: Vec = prefixes + .iter() + .map(|prefix| { + serde_json::json!({ + "nullifier_hash": { "_like": format!("{}%", prefix) } + }) + }) + .collect(); + + let mut where_clause = serde_json::Map::new(); + where_clause.insert("_or".to_string(), serde_json::Value::Array(or_conditions)); if let Some(block) = params.after_block { - input["afterBlock"] = serde_json::json!(block); + where_clause.insert( + "block".to_string(), + serde_json::json!({ "height": { "_gte": block } }), + ); } let request = GraphQLRequest { query: query.to_string(), - variables: serde_json::json!({ "input": input }), + variables: serde_json::json!({ + "where": serde_json::Value::Object(where_clause), + }), }; - 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)))?; - - 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("; ") - ))); - } - - let data = graphql_response - .data - .ok_or_else(|| QuantusError::Generic("No data in response".to_string()))?; + let data: HasuraNullifiersData = self.execute(&request).await?; - Ok(data.nullifiers_by_prefix.nullifiers) + Ok(data.nullifiers.into_iter().map(NullifierResult::from).collect()) } /// Check if specific nullifiers have been spent. diff --git a/src/subsquid/types.rs b/src/subsquid/types.rs index d507239..2ff0c19 100644 --- a/src/subsquid/types.rs +++ b/src/subsquid/types.rs @@ -47,15 +47,129 @@ pub struct Transfer { pub transfer_count: String, } -/// Result from a prefix query. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TransfersByPrefixResult { - /// Matching transfers - pub transfers: Vec, +/// Deserialize a Hasura `numeric` scalar into a `String`. +/// +/// Hasura serializes Postgres `numeric` columns as JSON numbers by default +/// (or strings when `HASURA_GRAPHQL_STRINGIFY_NUMERIC_TYPES` is set), so +/// accept both representations. +fn numeric_string<'de, D>(deserializer: D) -> std::result::Result +where + D: serde::Deserializer<'de>, +{ + struct NumericVisitor; + + impl serde::de::Visitor<'_> for NumericVisitor { + type Value = String; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a number or a numeric string") + } + + fn visit_str(self, v: &str) -> std::result::Result { + Ok(v.to_string()) + } + + fn visit_u64(self, v: u64) -> std::result::Result { + Ok(v.to_string()) + } + + fn visit_i64(self, v: i64) -> std::result::Result { + Ok(v.to_string()) + } + + fn visit_f64(self, v: f64) -> std::result::Result { + if v.fract() == 0.0 && v.is_finite() { + Ok(format!("{:.0}", v)) + } else { + Ok(v.to_string()) + } + } + } + + deserializer.deserialize_any(NumericVisitor) +} + +/// Nested `block { height }` relationship in Hasura responses. +#[derive(Debug, Clone, Deserialize)] +pub struct HasuraBlockRef { + pub height: i64, +} + +/// A transfer row as returned by the Hasura GraphQL server. +/// +/// Uses snake_case column names and nested relationships; converted into the +/// flat [`Transfer`] struct that the rest of the codebase consumes. +#[derive(Debug, Clone, Deserialize)] +pub struct HasuraTransferRow { + pub id: String, + pub block_id: Option, + pub block: Option, + pub timestamp: String, + pub extrinsic_id: Option, + pub from_id: Option, + pub to_id: Option, + #[serde(deserialize_with = "numeric_string")] + pub amount: String, + #[serde(deserialize_with = "numeric_string")] + pub fee: String, + pub from_hash: String, + pub to_hash: String, + #[serde(deserialize_with = "numeric_string")] + pub leaf_index: String, + #[serde(deserialize_with = "numeric_string")] + pub transfer_count: String, +} + +impl From for Transfer { + fn from(row: HasuraTransferRow) -> Self { + Transfer { + id: row.id, + block_id: row.block_id.unwrap_or_default(), + block_height: row.block.map(|b| b.height).unwrap_or_default(), + timestamp: row.timestamp, + extrinsic_hash: row.extrinsic_id, + from_id: row.from_id.unwrap_or_default(), + to_id: row.to_id.unwrap_or_default(), + amount: row.amount, + fee: row.fee, + from_hash: row.from_hash, + to_hash: row.to_hash, + leaf_index: row.leaf_index, + transfer_count: row.transfer_count, + } + } +} + +/// A wormhole nullifier row as returned by the Hasura GraphQL server. +#[derive(Debug, Clone, Deserialize)] +pub struct HasuraNullifierRow { + pub nullifier: String, + pub nullifier_hash: String, + pub block: Option, + pub timestamp: String, + #[serde(rename = "wormholeExtrinsic")] + pub wormhole_extrinsic: Option, +} + +/// Nested `wormholeExtrinsic { extrinsic_id }` relationship. +#[derive(Debug, Clone, Deserialize)] +pub struct HasuraWormholeExtrinsicRef { + pub extrinsic_id: Option, +} - /// Total count of matches - pub total_count: i64, +impl From for NullifierResult { + fn from(row: HasuraNullifierRow) -> Self { + NullifierResult { + nullifier: row.nullifier, + nullifier_hash: row.nullifier_hash, + extrinsic_hash: row + .wormhole_extrinsic + .and_then(|e| e.extrinsic_id) + .unwrap_or_default(), + block_height: row.block.map(|b| b.height).unwrap_or_default(), + timestamp: row.timestamp, + } + } } /// GraphQL response wrapper. @@ -100,17 +214,6 @@ pub struct NullifierResult { pub timestamp: String, } -/// Result from a nullifier prefix query. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NullifiersByPrefixResult { - /// Matching nullifiers - pub nullifiers: Vec, - - /// Total count of matches - pub total_count: i64, -} - /// Query parameters for nullifier prefix queries. #[derive(Debug, Clone, Default)] pub struct NullifierQueryParams { @@ -286,20 +389,86 @@ mod tests { } #[test] - fn test_graphql_response_with_data() { + fn test_hasura_transfer_row_deserialization() { + // Hasura returns numeric columns as JSON numbers by default. let json = r#"{ - "data": { - "transfers": [], - "totalCount": 0 - } + "id": "transfer-123", + "block_id": "block-456", + "block": { "height": 12345 }, + "timestamp": "2024-01-15T12:30:00+00:00", + "extrinsic_id": "0xabcd1234", + "from_id": "qzAlice123", + "to_id": "qzBob456", + "amount": 1000000000000, + "fee": 1000000, + "from_hash": "abcd1234", + "to_hash": "5678efgh", + "leaf_index": 42, + "transfer_count": 100 }"#; - let response: GraphQLResponse = - serde_json::from_str(json).expect("should deserialize"); + let row: HasuraTransferRow = serde_json::from_str(json).expect("should deserialize"); + let transfer: Transfer = row.into(); + + assert_eq!(transfer.id, "transfer-123"); + assert_eq!(transfer.block_id, "block-456"); + assert_eq!(transfer.block_height, 12345); + 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"); + assert_eq!(transfer.leaf_index, "42"); + assert_eq!(transfer.transfer_count, "100"); + } + + #[test] + fn test_hasura_transfer_row_stringified_numerics_and_nulls() { + // With HASURA_GRAPHQL_STRINGIFY_NUMERIC_TYPES enabled, numerics come as strings. + let json = r#"{ + "id": "transfer-123", + "block_id": null, + "block": null, + "timestamp": "2024-01-15T12:30:00+00:00", + "extrinsic_id": null, + "from_id": null, + "to_id": null, + "amount": "1000000000000", + "fee": "0", + "from_hash": "abcd1234", + "to_hash": "5678efgh", + "leaf_index": "0", + "transfer_count": "1" + }"#; + + let row: HasuraTransferRow = serde_json::from_str(json).expect("should deserialize"); + let transfer: Transfer = row.into(); + + assert_eq!(transfer.block_id, ""); + assert_eq!(transfer.block_height, 0); + assert!(transfer.extrinsic_hash.is_none()); + assert_eq!(transfer.amount, "1000000000000"); + assert_eq!(transfer.fee, "0"); + } + + #[test] + fn test_hasura_nullifier_row_deserialization() { + let json = r#"{ + "nullifier": "0xdeadbeef", + "nullifier_hash": "aabbccdd", + "block": { "height": 777 }, + "timestamp": "2024-02-01T00:00:00+00:00", + "wormholeExtrinsic": { "extrinsic_id": "0xfeed" } + }"#; + + let row: HasuraNullifierRow = serde_json::from_str(json).expect("should deserialize"); + let result: NullifierResult = row.into(); - assert!(response.data.is_some()); - assert!(response.errors.is_none()); - assert_eq!(response.data.unwrap().total_count, 0); + assert_eq!(result.nullifier, "0xdeadbeef"); + assert_eq!(result.nullifier_hash, "aabbccdd"); + assert_eq!(result.extrinsic_hash, "0xfeed"); + assert_eq!(result.block_height, 777); + assert_eq!(result.timestamp, "2024-02-01T00:00:00+00:00"); } #[test] @@ -310,12 +479,12 @@ mod tests { { "message": "Query returned too many results", "locations": [{"line": 1, "column": 1}], - "path": ["transfersByHashPrefix"] + "path": ["transfer"] } ] }"#; - let response: GraphQLResponse = + let response: GraphQLResponse = serde_json::from_str(json).expect("should deserialize"); assert!(response.data.is_none()); From fabeb0fae56377bc1f17411b263f631ee2129d7b Mon Sep 17 00:00:00 2001 From: Beast Date: Fri, 12 Jun 2026 14:11:31 +0800 Subject: [PATCH 2/2] chore: formatting --- src/subsquid/client.rs | 6 ++---- src/subsquid/types.rs | 5 +---- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/subsquid/client.rs b/src/subsquid/client.rs index 7655a24..7b0b705 100644 --- a/src/subsquid/client.rs +++ b/src/subsquid/client.rs @@ -398,10 +398,8 @@ impl SubsquidClient { where_clause.insert("_or".to_string(), serde_json::Value::Array(or_conditions)); if let Some(block) = params.after_block { - where_clause.insert( - "block".to_string(), - serde_json::json!({ "height": { "_gte": block } }), - ); + where_clause + .insert("block".to_string(), serde_json::json!({ "height": { "_gte": block } })); } let request = GraphQLRequest { diff --git a/src/subsquid/types.rs b/src/subsquid/types.rs index 2ff0c19..bfdad3d 100644 --- a/src/subsquid/types.rs +++ b/src/subsquid/types.rs @@ -162,10 +162,7 @@ impl From for NullifierResult { NullifierResult { nullifier: row.nullifier, nullifier_hash: row.nullifier_hash, - extrinsic_hash: row - .wormhole_extrinsic - .and_then(|e| e.extrinsic_id) - .unwrap_or_default(), + extrinsic_hash: row.wormhole_extrinsic.and_then(|e| e.extrinsic_id).unwrap_or_default(), block_height: row.block.map(|b| b.height).unwrap_or_default(), timestamp: row.timestamp, }