Skip to main content

nova_sdk_rs/
lib.rs

1// nova-sdk-rs v1.0.1 - NOVA SDK for Rust
2use near_jsonrpc_client::{methods, JsonRpcClient};
3use near_jsonrpc_primitives::types::query::QueryResponseKind as JsonRpcQueryResponseKind;
4use near_primitives::types::{AccountId, Balance, BlockReference, Finality};
5use near_primitives::views::QueryRequest;
6use thiserror::Error;
7use std::str::FromStr;
8use std::sync::Arc;
9use tokio::sync::RwLock;
10use serde_json::json;
11use serde::Deserialize;
12use sha2::{Sha256, Digest};
13use reqwest::Client;
14use aes_gcm::{
15    aead::{Aead, KeyInit, OsRng},
16    Aes256Gcm, Nonce,
17};
18use rand::RngCore;
19
20// Infrastructure endpoints (public, immutable)
21const DEFAULT_MCP_URL: &str = "https://5a5223f7d1bfe777433c496b9d52ff851e927259-8000.dstack-prod5.phala.network";
22const DEFAULT_RPC_URL: &str = "https://rpc.mainnet.near.org";
23const DEFAULT_CONTRACT_ID: &str = "nova-sdk.near";
24const DEFAULT_AUTH_URL: &str = "https://nova-sdk.com";
25
26#[derive(Error, Debug)]
27pub enum NovaError {
28    #[error("NEAR RPC error: {0}")]
29    Near(String),
30    #[error("MCP error: {0}")]
31    Mcp(String),
32    #[error("Account ID parse failed")]
33    ParseAccount,
34    #[error("Invalid CID: {0}")]
35    InvalidCid(String),
36    #[error("Authentication error: {0}")]
37    Auth(String),
38    #[error("HTTP error: {0}")]
39    Http(String),
40    #[error("Encryption error: {0}")]
41    Encryption(String),
42    #[error("Decryption error: {0}")]
43    Decryption(String),
44    #[error("Token error: {0}")]
45    Token(String),
46}
47
48impl From<reqwest::Error> for NovaError {
49    fn from(e: reqwest::Error) -> Self {
50        NovaError::Http(e.to_string())
51    }
52}
53
54impl From<aes_gcm::Error> for NovaError {
55    fn from(e: aes_gcm::Error) -> Self {
56        NovaError::Encryption(format!("AES-GCM error: {:?}", e))
57    }
58}
59
60// Public types
61#[derive(Deserialize, Debug, Clone)]
62pub struct Transaction {
63    pub group_id: String,
64    pub user_id: String,
65    pub file_hash: String,
66    pub ipfs_hash: String,
67}
68
69#[derive(Debug, Clone)]
70pub struct UploadResult {
71    pub cid: String,
72    pub trans_id: String,
73    pub file_hash: String,
74}
75
76#[derive(Debug)]
77pub struct RetrieveResult {
78    pub data: Vec<u8>,
79    pub ipfs_hash: String,
80    pub group_id: String,
81}
82
83#[derive(Deserialize, Debug)]
84pub struct AuthStatusResult {
85    pub authenticated: bool,
86    pub near_account_id: Option<String>,
87    pub authorized_for_group: Option<bool>,
88}
89
90// Internal types
91#[derive(Deserialize, Debug)]
92struct PrepareUploadResponse {
93    upload_id: String,
94    key: String,
95    group_id: String,
96    filename: String,
97}
98
99#[derive(Deserialize, Debug)]
100struct FinalizeUploadResponse {
101    cid: String,
102    trans_id: String,
103    file_hash: String,
104}
105
106#[derive(Deserialize, Debug)]
107struct PrepareRetrieveResponse {
108    key: String,
109    encrypted_b64: String,
110    ipfs_hash: String,
111    group_id: String,
112}
113
114#[derive(Deserialize, Debug)]
115struct McpMessageResponse {
116    message: Option<String>,
117}
118
119#[derive(Deserialize, Debug)]
120struct SessionTokenResponse {
121    token: String,
122    account_id: String,
123    expires_in: String,
124}
125
126// Token cache
127#[derive(Debug, Clone)]
128struct TokenCache {
129    token: String,
130    expires_at: u64, // Unix timestamp in milliseconds
131}
132
133// Configuration for NovaSdk
134#[derive(Clone)]
135pub struct NovaSdkConfig {
136    pub api_key: Option<String>,
137    pub auth_url: String,
138    pub rpc_url: String,
139    pub contract_id: String,
140    pub mcp_url: String,
141}
142
143impl Default for NovaSdkConfig {
144    fn default() -> Self {
145        Self {
146            api_key: None,
147            auth_url: DEFAULT_AUTH_URL.to_string(),
148            rpc_url: DEFAULT_RPC_URL.to_string(),
149            contract_id: DEFAULT_CONTRACT_ID.to_string(),
150            mcp_url: DEFAULT_MCP_URL.to_string(),
151        }
152    }
153}
154
155impl NovaSdkConfig {
156    /// Create config for testnet
157    pub fn testnet() -> Self {
158        Self {
159            api_key: None,
160            auth_url: DEFAULT_AUTH_URL.to_string(),
161            rpc_url: "https://rpc.testnet.near.org".to_string(),
162            contract_id: "nova-sdk-6.testnet".to_string(),
163            mcp_url: DEFAULT_MCP_URL.to_string(),
164        }
165    }
166
167    /// Create config for mainnet
168    pub fn mainnet() -> Self {
169        Self::default()
170    }
171
172    /// Set the API key for authentication
173    pub fn with_api_key(mut self, api_key: &str) -> Self {
174        self.api_key = Some(api_key.to_string());
175        self
176    }
177}
178
179// encryption/decryption helpers
180fn encrypt_data(data: &[u8], key_b64: &str) -> Result<String, NovaError> {
181    use base64::Engine;
182    
183    let key_bytes = base64::engine::general_purpose::STANDARD
184        .decode(key_b64)
185        .map_err(|e| NovaError::Encryption(format!("Invalid key: {}", e)))?;
186    
187    if key_bytes.len() != 32 {
188        return Err(NovaError::Encryption(format!(
189            "Key must be 32 bytes, got {}",
190            key_bytes.len()
191        )));
192    }
193    
194    // Generate random 12-byte IV
195    let mut iv = [0u8; 12];
196    OsRng.fill_bytes(&mut iv);
197    let nonce = Nonce::from_slice(&iv);
198    
199    // Create cipher and encrypt
200    let cipher = Aes256Gcm::new_from_slice(&key_bytes)
201        .map_err(|e| NovaError::Encryption(format!("Cipher init failed: {:?}", e)))?;
202    
203    let ciphertext = cipher
204        .encrypt(nonce, data)
205        .map_err(|e| NovaError::Encryption(format!("Encryption failed: {:?}", e)))?;
206    
207    // Combine: IV (12 bytes) + ciphertext (includes auth tag)
208    let mut result = Vec::with_capacity(12 + ciphertext.len());
209    result.extend_from_slice(&iv);
210    result.extend_from_slice(&ciphertext);
211    
212    Ok(base64::engine::general_purpose::STANDARD.encode(&result))
213}
214
215fn decrypt_data(encrypted_b64: &str, key_b64: &str) -> Result<Vec<u8>, NovaError> {
216    use base64::Engine;
217    
218    let encrypted_bytes = base64::engine::general_purpose::STANDARD
219        .decode(encrypted_b64)
220        .map_err(|e| NovaError::Decryption(format!("Invalid encrypted data: {}", e)))?;
221    
222    if encrypted_bytes.len() < 28 {
223        // 12 (IV) + 16 (min auth tag)
224        return Err(NovaError::Decryption("Encrypted data too short".to_string()));
225    }
226    
227    let key_bytes = base64::engine::general_purpose::STANDARD
228        .decode(key_b64)
229        .map_err(|e| NovaError::Decryption(format!("Invalid key: {}", e)))?;
230    
231    if key_bytes.len() != 32 {
232        return Err(NovaError::Decryption(format!(
233            "Key must be 32 bytes, got {}",
234            key_bytes.len()
235        )));
236    }
237    
238    let iv = &encrypted_bytes[0..12];
239    let ciphertext = &encrypted_bytes[12..];
240    let nonce = Nonce::from_slice(iv);
241    
242    let cipher = Aes256Gcm::new_from_slice(&key_bytes)
243        .map_err(|e| NovaError::Decryption(format!("Cipher init failed: {:?}", e)))?;
244    
245    cipher
246        .decrypt(nonce, ciphertext)
247        .map_err(|e| NovaError::Decryption(format!("Decryption failed: {:?}", e)))
248}
249
250/// NOVA SDK - Secure file sharing on NEAR Protocol
251/// 
252/// # Example
253/// ```no_run
254/// use nova_sdk_rs::NovaSdk;
255/// 
256/// #[tokio::main]
257/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
258///     // Simplest usage - automatic token management
259///     let sdk = NovaSdk::new("alice.nova-sdk.near")?;
260///     
261///     // Upload a file
262///     let result = sdk.upload("my-group", b"Hello NOVA!", "hello.txt").await?;
263///     println!("Uploaded: {}", result.cid);
264///     
265///     // Retrieve the file
266///     let retrieved = sdk.retrieve("my-group", &result.cid).await?;
267///     println!("Retrieved: {} bytes", retrieved.data.len());
268///     
269///     Ok(())
270/// }
271/// ```
272
273#[derive(Debug)]
274pub struct NovaSdk {
275    client: JsonRpcClient,
276    http_client: Client,
277    account_id: String,
278    contract_id: AccountId,
279    auth_url: String,
280    api_key: Option<String>,
281    mcp_url: String,
282    rpc_url: String,
283    network_id: String,
284    token_cache: Arc<RwLock<Option<TokenCache>>>,
285}
286
287impl NovaSdk {
288    /// Creates a new NovaSdk instance with `account_id` - automatic token management.
289    pub fn new(account_id: &str) -> Result<Self, NovaError> {
290        Self::with_config(account_id, NovaSdkConfig::default())
291    }
292
293    /// Creates a new NovaSdk instance for testnet.
294    pub fn testnet(account_id: &str) -> Result<Self, NovaError> {
295        Self::with_config(account_id, NovaSdkConfig::testnet())
296    }
297
298    /// Creates a new NovaSdk instance with custom configuration.
299    pub fn with_config(
300        account_id: &str,
301        config: NovaSdkConfig,
302    ) -> Result<Self, NovaError> {
303        if account_id.is_empty() {
304            return Err(NovaError::Auth("account_id required: get yours at nova-sdk.com".to_string()));
305        }
306
307        let contract_id = AccountId::from_str(&config.contract_id)
308            .map_err(|_| NovaError::ParseAccount)?;
309
310        // Auto-detect network
311        let network_id = Self::detect_network(&contract_id, &config.rpc_url);
312        
313        // Validate mainnet contract
314        if network_id == "mainnet" && !Self::is_valid_mainnet_contract(&contract_id) {
315            return Err(NovaError::Auth(format!(
316                "Invalid mainnet contract: {}. Must end with .near",
317                contract_id
318            )));
319        }
320        
321        // Mainnet warning
322        if network_id == "mainnet" {
323            eprintln!("⚠️  MAINNET MODE: Operations use real NEAR tokens.");
324            eprintln!("📋 Contract: {}", contract_id);
325            eprintln!("💰 Check costs at: https://github.com/jcarbonnell/nova");
326        }
327
328        Ok(Self {
329            client: JsonRpcClient::connect(&config.rpc_url),
330            http_client: Client::new(),
331            account_id: account_id.to_string(),
332            contract_id,
333            auth_url: config.auth_url,
334            api_key: config.api_key,
335            mcp_url: config.mcp_url,
336            rpc_url: config.rpc_url,
337            network_id,
338            token_cache: Arc::new(RwLock::new(None)),
339        })
340    }
341
342    // Token Management
343    /// Get a valid session token, fetching or refreshing if needed.
344    async fn get_session_token(&self) -> Result<String, NovaError> {
345        // Require API key
346        let api_key = self.api_key.as_ref().ok_or_else(|| {
347            NovaError::Auth("API key required. Get yours at nova-sdk.com".to_string())
348        })?;
349        
350        let now_ms = std::time::SystemTime::now()
351            .duration_since(std::time::UNIX_EPOCH)
352            .unwrap()
353            .as_millis() as u64;
354
355        // Check cached token (with 5 minute buffer)
356        {
357            let cache = self.token_cache.read().await;
358            if let Some(ref tc) = *cache {
359                if tc.expires_at > now_ms + 5 * 60 * 1000 {
360                    return Ok(tc.token.clone());
361                }
362            }
363        }
364
365        // Fetch new token
366        println!("🔑 Fetching session token for: {}", self.account_id);
367
368        let response = self
369            .http_client
370            .post(format!("{}/api/auth/session-token", self.auth_url))
371            .header("Content-Type", "application/json")
372            .header("X-API-Key", api_key)
373            .json(&json!({ "account_id": self.account_id }))
374            .timeout(std::time::Duration::from_secs(15))
375            .send()
376            .await?;
377
378        if !response.status().is_success() {
379            let status = response.status();
380            let error_text = response.text().await.unwrap_or_default();
381            
382            if status.as_u16() == 404 {
383                return Err(NovaError::Token(format!(
384                    "Account '{}' not found. Create one at nova-sdk.com first.",
385                    self.account_id
386                )));
387            }
388            
389            // Try to parse error message
390            let error_msg = if let Ok(json) = serde_json::from_str::<serde_json::Value>(&error_text) {
391                json.get("error")
392                    .and_then(|v| v.as_str())
393                    .unwrap_or(&error_text)
394                    .to_string()
395            } else {
396                error_text
397            };
398
399            return Err(NovaError::Token(format!(
400                "Failed to get session token ({}): {}",
401                status, error_msg
402            )));
403        }
404
405        let token_response: SessionTokenResponse = response
406            .json()
407            .await
408            .map_err(|e| NovaError::Token(format!("Failed to parse token response: {}", e)))?;
409
410        // Verify account_id matches
411        if token_response.account_id != self.account_id {
412            eprintln!(
413                "⚠️  Account ID mismatch: requested {}, got {}",
414                self.account_id, token_response.account_id
415            );
416        }
417
418        // Parse expires_in and cache token
419        let expires_ms = Self::parse_expiry(&token_response.expires_in);
420        
421        {
422            let mut cache = self.token_cache.write().await;
423            *cache = Some(TokenCache {
424                token: token_response.token.clone(),
425                expires_at: now_ms + expires_ms,
426            });
427        }
428
429        println!("✅ Session token obtained, expires in: {}", token_response.expires_in);
430        Ok(token_response.token)
431    }
432
433    fn parse_expiry(expires_in: &str) -> u64 {
434        // Parse "24h", "30m", "7d" etc.
435        let chars: Vec<char> = expires_in.chars().collect();
436        if chars.is_empty() {
437            return 23 * 60 * 60 * 1000; // Default 23h
438        }
439
440        let unit = chars.last().unwrap();
441        let value_str: String = chars[..chars.len()-1].iter().collect();
442        let value: u64 = value_str.parse().unwrap_or(23);
443
444        match unit {
445            'h' => value * 60 * 60 * 1000,
446            'm' => value * 60 * 1000,
447            'd' => value * 24 * 60 * 60 * 1000,
448            _ => 23 * 60 * 60 * 1000,
449        }
450    }
451
452    /// Force refresh the session token.
453    /// 
454    /// Useful if you get auth errors and want to retry with a fresh token.
455    pub async fn refresh_token(&self) -> Result<(), NovaError> {
456        {
457            let mut cache = self.token_cache.write().await;
458            *cache = None;
459        }
460        self.get_session_token().await?;
461        Ok(())
462    }
463
464    // Network detection
465    fn detect_network(contract_id: &AccountId, rpc_url: &str) -> String {
466        let contract_str = contract_id.as_str();
467        
468        if contract_str.ends_with(".testnet") {
469            return "testnet".to_string();
470        }
471        if contract_str.ends_with(".near") {
472            return "mainnet".to_string();
473        }
474        
475        if rpc_url.contains("testnet") {
476            return "testnet".to_string();
477        }
478        if rpc_url.contains("mainnet") {
479            return "mainnet".to_string();
480        }
481        
482        // Default to mainnet
483        eprintln!("⚠️  Network auto-detection failed, defaulting to mainnet");
484        "mainnet".to_string()
485    }
486
487    fn is_valid_mainnet_contract(contract_id: &AccountId) -> bool {
488        contract_id.as_str().ends_with(".near")
489    }
490
491    // Public accessors
492    pub fn account_id(&self) -> &str {
493        &self.account_id
494    }
495
496    pub fn contract_id(&self) -> &str {
497        self.contract_id.as_str()
498    }
499
500    pub fn mcp_url(&self) -> &str {
501        &self.mcp_url
502    }
503
504    pub fn rpc_url(&self) -> &str {
505        &self.rpc_url
506    }
507
508    pub fn network_id(&self) -> &str {
509        &self.network_id
510    }
511
512    pub fn auth_url(&self) -> &str {
513        &self.auth_url
514    }
515
516    // Get network info for debugging
517    pub fn get_network_info(&self) -> (String, String, String, String) {
518        (
519            self.network_id.clone(),
520            self.contract_id.to_string(),
521            self.rpc_url.clone(),
522            self.auth_url.clone(),
523        )
524    }
525
526    // MCP Communication
527    async fn call_mcp_tool<T: for<'de> Deserialize<'de>>(
528        &self,
529        tool_name: &str,
530        args: serde_json::Value,
531    ) -> Result<T, NovaError> {
532        let token = self.get_session_token().await?;
533        // REST tools are at /tools/ — MCP protocol endpoint is at /mcp
534        let url = format!("{}/tools/{}", self.mcp_url, tool_name);
535
536        let response = self
537            .http_client
538            .post(&url)
539            .header("Content-Type", "application/json")
540            .header("Authorization", format!("Bearer {}", token))
541            .header("x-account-id", &self.account_id)
542            .header("x-wallet-id", &self.account_id) // shade agent salt reconstruction
543            .json(&args)
544            .timeout(std::time::Duration::from_secs(60))
545            .send()
546            .await?;
547
548        if !response.status().is_success() {
549            let status = response.status();
550            let error_text = response.text().await.unwrap_or_default();
551            
552            let error_msg = if let Ok(json) = serde_json::from_str::<serde_json::Value>(&error_text) {
553                json.get("error")
554                    .or(json.get("message"))
555                    .and_then(|v| v.as_str())
556                    .unwrap_or(&error_text)
557                    .to_string()
558            } else {
559                error_text
560            };
561
562            return Err(NovaError::Mcp(format!(
563                "MCP tool '{}' failed ({}): {}",
564                tool_name, status, error_msg
565            )));
566        }
567
568        // Unwrap { result: ... } envelope added by expose_as_rest decorator
569        let raw: serde_json::Value = response
570            .json()
571            .await
572            .map_err(|e| NovaError::Mcp(format!("Failed to parse MCP response: {}", e)))?;
573
574        let inner = if let Some(result) = raw.get("result") {
575            result.clone()
576        } else {
577            raw
578        };
579
580        serde_json::from_value::<T>(inner)
581            .map_err(|e| NovaError::Mcp(format!("Failed to deserialize MCP response: {}", e)))
582    }
583
584    /// core NOVA operations
585    /// Check authentication status and group authorization.
586    pub async fn auth_status(&self, group_id: Option<&str>) -> Result<AuthStatusResult, NovaError> {
587        let args = json!({
588            "group_id": group_id.unwrap_or("default")
589        });
590        self.call_mcp_tool("auth_status", args).await
591    }
592
593    /// Register a new group. Caller becomes owner.
594    pub async fn register_group(&self, group_id: &str) -> Result<String, NovaError> {
595        let args = json!({ "group_id": group_id });
596        let response: McpMessageResponse = self.call_mcp_tool("register_group", args).await?;
597        Ok(response.message.unwrap_or_else(|| format!("Group '{}' registered successfully", group_id)))
598    }
599
600    /// Add a member to a group (owner only).
601    pub async fn add_group_member(&self, group_id: &str, member_id: &str) -> Result<String, NovaError> {
602        let args = json!({
603            "group_id": group_id,
604            "member_id": member_id
605        });
606        let response: McpMessageResponse = self.call_mcp_tool("add_group_member", args).await?;
607        Ok(response.message.unwrap_or_else(|| format!("Added {} to group '{}'", member_id, group_id)))
608    }
609
610    /// Revoke a member from a group (owner only, triggers key rotation).
611    pub async fn revoke_group_member(&self, group_id: &str, member_id: &str) -> Result<String, NovaError> {
612        let args = json!({
613            "group_id": group_id,
614            "member_id": member_id
615        });
616        let response: McpMessageResponse = self.call_mcp_tool("revoke_group_member", args).await?;
617        Ok(response.message.unwrap_or_else(|| format!("Revoked {} from group '{}'", member_id, group_id)))
618    }
619
620    /// Upload a file with end-to-end encryption.
621    ///
622    /// Flow:
623    /// 1. SDK calls prepare_upload to get encryption key
624    /// 2. SDK encrypts data locally (AES-256-GCM)
625    /// 3. SDK calls finalize_upload with encrypted data
626    /// 4. MCP uploads to IPFS and records on NEAR
627    ///
628    /// # Arguments
629    /// * `group_id` - The group to upload to
630    /// * `data` - Raw file data
631    /// * `filename` - Name of the file
632    ///
633    /// # Returns
634    /// Upload result with CID and transaction ID
635    pub async fn upload(
636        &self,
637        group_id: &str,
638        data: &[u8],
639        filename: &str,
640    ) -> Result<UploadResult, NovaError> {
641        // Step 1: Get encryption key from MCP
642        let args = json!({
643            "group_id": group_id,
644            "filename": filename
645        });
646        let prepare_result: PrepareUploadResponse =
647            self.call_mcp_tool("prepare_upload", args).await?;
648
649        let upload_id = prepare_result.upload_id;
650        let key = prepare_result.key;
651
652        // Step 2: Encrypt data locally
653        let encrypted_b64 = encrypt_data(data, &key)?;
654
655        // Step 3: Compute hash of plaintext
656        let file_hash = Self::compute_hash(data);
657
658        // Step 4: Finalize upload
659        let body = json!({
660            "upload_id": upload_id,
661            "encrypted_data": encrypted_b64,
662            "file_hash": file_hash
663        });
664        let finalize_result: FinalizeUploadResponse =
665            self.call_mcp_tool("finalize_upload", body).await?;
666
667        Ok(UploadResult {
668            cid: finalize_result.cid,
669            trans_id: finalize_result.trans_id,
670            file_hash: finalize_result.file_hash,
671        })
672    }
673
674    /// Retrieve and decrypt a file.
675    ///
676    /// Flow:
677    /// 1. Call prepare_retrieve to get key and encrypted data
678    /// 2. Decrypt data locally (client-side)
679    ///
680    /// # Arguments
681    /// * `group_id` - The group the file belongs to
682    /// * `ipfs_hash` - The IPFS CID of the file
683    ///
684    /// # Returns
685    /// Decrypted file data
686    pub async fn retrieve(
687        &self,
688        group_id: &str,
689        ipfs_hash: &str,
690    ) -> Result<RetrieveResult, NovaError> {
691        if !ipfs_hash.starts_with("Qm") && !ipfs_hash.starts_with("bafy") {
692            return Err(NovaError::InvalidCid(ipfs_hash.to_string()));
693        }
694
695        // Step 1: Get key and encrypted data from MCP
696        let args = json!({
697            "group_id": group_id,
698            "ipfs_hash": ipfs_hash
699        });
700        let prepare_result: PrepareRetrieveResponse =
701            self.call_mcp_tool("prepare_retrieve", args).await?;
702
703        // Step 2: Decrypt data locally
704        let decrypted_data = decrypt_data(&prepare_result.encrypted_b64, &prepare_result.key)?;
705
706        Ok(RetrieveResult {
707            data: decrypted_data,
708            ipfs_hash: prepare_result.ipfs_hash,
709            group_id: prepare_result.group_id,
710        })
711    }
712
713    /// Read-Only Contract Queries (Direct RPC - no auth needed)
714    
715    /// Get account balance in yoctoNEAR
716    pub async fn get_balance(&self, account_id: Option<&str>) -> Result<Balance, NovaError> {
717        let id = account_id.unwrap_or(&self.account_id);
718        let account_id_acc = AccountId::from_str(id).map_err(|_| NovaError::ParseAccount)?;
719        
720        let request = methods::query::RpcQueryRequest {
721            block_reference: BlockReference::Finality(Finality::Final),
722            request: QueryRequest::ViewAccount { account_id: account_id_acc },
723        };
724        
725        let response = self.client.call(request).await
726            .map_err(|e| NovaError::Near(e.to_string()))?;
727        
728        match response.kind {
729            JsonRpcQueryResponseKind::ViewAccount(acc) => Ok(acc.amount),
730            _ => Err(NovaError::Near("Invalid response kind".to_string())),
731        }
732    }
733
734    /// Checks if a user is authorized in a group
735    pub async fn is_authorized(&self, group_id: &str, user_id: Option<&str>) -> Result<bool, NovaError> {
736        let id = user_id.unwrap_or(&self.account_id);
737        let args = json!({"group_id": group_id, "user_id": id}).to_string().into_bytes();
738        
739        let request = methods::query::RpcQueryRequest {
740            block_reference: BlockReference::Finality(Finality::Final),
741            request: QueryRequest::CallFunction {
742                account_id: self.contract_id.clone(),
743                method_name: "is_authorized".to_string(),
744                args: args.into(),
745            },
746        };
747        
748        let response = self.client.call(request).await
749            .map_err(|e| NovaError::Near(e.to_string()))?;
750        
751        match response.kind {
752            JsonRpcQueryResponseKind::CallResult(result) => {
753                let bool_result: bool = serde_json::from_slice(&result.result)
754                    .map_err(|e| NovaError::Near(e.to_string()))?;
755                Ok(bool_result)
756            }
757            _ => Err(NovaError::Near("Invalid response kind".to_string())),
758        }
759    }
760
761    /// Get group checksum (Shade TEE attestation)
762    pub async fn get_group_checksum(&self, group_id: &str) -> Result<Option<String>, NovaError> {
763        let args = json!({"group_id": group_id}).to_string().into_bytes();
764        
765        let request = methods::query::RpcQueryRequest {
766            block_reference: BlockReference::Finality(Finality::Final),
767            request: QueryRequest::CallFunction {
768                account_id: self.contract_id.clone(),
769                method_name: "get_group_checksum".to_string(),
770                args: args.into(),
771            },
772        };
773        
774        let response = self.client.call(request).await
775            .map_err(|e| NovaError::Near(e.to_string()))?;
776        
777        match response.kind {
778            JsonRpcQueryResponseKind::CallResult(result) => {
779                if result.result.is_empty() {
780                    return Ok(None);
781                }
782                let checksum: Option<String> = serde_json::from_slice(&result.result)
783                    .map_err(|e| NovaError::Near(e.to_string()))?;
784                Ok(checksum)
785            }
786            _ => Err(NovaError::Near("Invalid response kind".to_string())),
787        }
788    }
789
790    /// Get group owner.
791    pub async fn get_group_owner(&self, group_id: &str) -> Result<Option<String>, NovaError> {
792        let args = json!({"group_id": group_id}).to_string().into_bytes();
793        
794        let request = methods::query::RpcQueryRequest {
795            block_reference: BlockReference::Finality(Finality::Final),
796            request: QueryRequest::CallFunction {
797                account_id: self.contract_id.clone(),
798                method_name: "get_group_owner".to_string(),
799                args: args.into(),
800            },
801        };
802        
803        let response = self.client.call(request).await
804            .map_err(|e| NovaError::Near(e.to_string()))?;
805        
806        match response.kind {
807            JsonRpcQueryResponseKind::CallResult(result) => {
808                if result.result.is_empty() {
809                    return Ok(None);
810                }
811                let owner: Option<String> = serde_json::from_slice(&result.result)
812                    .map_err(|e| NovaError::Near(e.to_string()))?;
813                Ok(owner)
814            }
815            _ => Err(NovaError::Near("Invalid response kind".to_string())),
816        }
817    }
818
819    /// Estimates fee for an action (yoctoNEAR, read-only view).
820    pub async fn estimate_fee(&self, action: &str) -> Result<u128, NovaError> {
821        let args = json!({"action": action}).to_string().into_bytes();
822        
823        let request = methods::query::RpcQueryRequest {
824            block_reference: BlockReference::Finality(Finality::Final),
825            request: QueryRequest::CallFunction {
826                account_id: self.contract_id.clone(),
827                method_name: "estimate_fee".to_string(),
828                args: args.into(),
829            },
830        };
831        
832        let response = self.client.call(request).await
833            .map_err(|e| NovaError::Near(e.to_string()))?;
834        
835        match response.kind {
836            JsonRpcQueryResponseKind::CallResult(result) => {
837                let fee: u128 = serde_json::from_slice(&result.result)
838                    .map_err(|e| NovaError::Near(e.to_string()))?;
839                Ok(fee)
840            }
841            _ => Err(NovaError::Near("Invalid response kind".to_string())),
842        }
843    }
844
845    /// get transactions for a group.
846    pub async fn get_transactions_for_group(
847        &self,
848        group_id: &str,
849        user_id: Option<&str>,
850    ) -> Result<Vec<Transaction>, NovaError> {
851        let id = user_id.unwrap_or(&self.account_id);
852        let args = json!({"group_id": group_id, "user_id": id}).to_string().into_bytes();
853        
854        let request = methods::query::RpcQueryRequest {
855            block_reference: BlockReference::Finality(Finality::Final),
856            request: QueryRequest::CallFunction {
857                account_id: self.contract_id.clone(),
858                method_name: "get_transactions_for_group".to_string(),
859                args: args.into(),
860            },
861        };
862        
863        let response = self.client.call(request).await
864            .map_err(|e| NovaError::Near(e.to_string()))?;
865        
866        match response.kind {
867            JsonRpcQueryResponseKind::CallResult(result) => {
868                let txs: Vec<Transaction> = serde_json::from_slice(&result.result)
869                    .map_err(|e| NovaError::Near(format!("Failed to parse transactions: {}", e)))?;
870                Ok(txs)
871            }
872            _ => Err(NovaError::Near("Invalid response kind".to_string())),
873        }
874    }
875
876    /// Compute SHA256 hash of data.
877    pub fn compute_hash(data: &[u8]) -> String {
878        let mut hasher = Sha256::new();
879        hasher.update(data);
880        let result = hasher.finalize();
881        hex::encode(result)
882    }
883}
884
885#[cfg(test)]
886mod tests {
887    use super::*;
888    use std::env;
889
890    // Mock session token for unit tests (not valid for real MCP calls)
891    const MOCK_SESSION_TOKEN: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50X2lkIjoiYWxpY2Utbm92YS5ub3ZhLXNkay01LnRlc3RuZXQiLCJ0eXBlIjoibm92YV9zZXNzaW9uIn0.mock";
892    const TEST_ACCOUNT_ID: &str = "alice-nova.nova-sdk-6.testnet";
893
894    // =========================================================================
895    // Constructor Tests
896    // =========================================================================
897
898    fn make_sdk(account_id: &str) -> Result<NovaSdk, NovaError> {
899        let config = NovaSdkConfig::default()
900            .with_api_key("nova_sk_testkey1234567890123456789012345678901");
901        NovaSdk::with_config(account_id, config)
902    }
903
904    #[test]
905    fn test_new_success() {
906        let result = make_sdk(TEST_ACCOUNT_ID);
907        assert!(result.is_ok());
908        let sdk = result.unwrap();
909        assert_eq!(sdk.account_id(), TEST_ACCOUNT_ID);
910        assert_eq!(sdk.contract_id(), DEFAULT_CONTRACT_ID);
911        assert_eq!(sdk.mcp_url(), DEFAULT_MCP_URL);
912        assert_eq!(sdk.rpc_url(), DEFAULT_RPC_URL);
913    }
914
915    #[test]
916    fn test_new_requires_account_id() {
917        let result = make_sdk("");
918        assert!(result.is_err());
919        let err = result.unwrap_err();
920        assert!(matches!(err, NovaError::Auth(_)));
921        assert!(err.to_string().contains("account_id required"));
922    }
923
924    #[test]
925    fn test_api_key_required_on_mcp_call() {
926        // SDK without api_key should fail lazily when first MCP call is made
927        let result = NovaSdk::new(TEST_ACCOUNT_ID);
928        assert!(result.is_ok()); // construction succeeds
929        // actual enforcement happens in get_session_token() at call time
930    }
931
932    // =========================================================================
933    // Utility Tests
934    // =========================================================================
935
936    #[test]
937    fn test_compute_hash() {
938        let hash = NovaSdk::compute_hash(b"test data");
939        assert_eq!(hash.len(), 64); // SHA256 hex = 64 chars
940        assert_eq!(hash, "916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9");
941    }
942
943    #[test]
944    fn test_compute_hash_consistency() {
945        let data = b"consistent data";
946        let hash1 = NovaSdk::compute_hash(data);
947        let hash2 = NovaSdk::compute_hash(data);
948        assert_eq!(hash1, hash2);
949    }
950
951    #[test]
952    fn test_compute_hash_different_data() {
953        let hash1 = NovaSdk::compute_hash(b"data1");
954        let hash2 = NovaSdk::compute_hash(b"data2");
955        assert_ne!(hash1, hash2);
956    }
957
958    #[test]
959    fn test_compute_hash_empty() {
960        let hash = NovaSdk::compute_hash(b"");
961        assert_eq!(hash.len(), 64);
962        // SHA256 of empty string
963        assert_eq!(hash, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
964    }
965
966    // =========================================================================
967    // CID Validation Tests
968    // =========================================================================
969
970    #[test]
971    fn test_valid_cid_format() {
972        assert!("QmXyz123456789abcdefghijklmnopqrstuvwxyz1234".starts_with("Qm"));
973        assert!("QmTest".starts_with("Qm"));
974    }
975
976    #[test]
977    fn test_invalid_cid_format() {
978        assert!(!"invalid_cid".starts_with("Qm"));
979        assert!(!"bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi".starts_with("Qm")); // CIDv1
980        assert!(!"".starts_with("Qm"));
981    }
982
983    // =========================================================================
984    // Read-Only RPC Tests (No Auth Required)
985    // =========================================================================
986
987    #[tokio::test]
988    async fn test_get_balance() {
989        let sdk = make_sdk(TEST_ACCOUNT_ID).unwrap();
990        // Query a known mainnet account (always exists)
991        let balance = sdk.get_balance(Some("nova-sdk.near")).await.unwrap();
992        let bal_str = balance.to_string();
993        assert!(!bal_str.is_empty());
994        assert!(bal_str.parse::<u128>().is_ok());
995    }
996
997    #[tokio::test]
998    async fn test_get_balance_default_account() {
999        let sdk = make_sdk("nova-sdk.near").unwrap();
1000        // Uses sdk.account_id by default — must be a real mainnet account
1001        let balance = sdk.get_balance(None).await.unwrap();
1002        assert!(balance > 0);
1003    }
1004
1005    #[tokio::test]
1006    async fn test_get_balance_nonexistent_account() {
1007        let sdk = make_sdk(TEST_ACCOUNT_ID).unwrap();
1008        let result = sdk.get_balance(Some("nonexistent.account.testnet")).await;
1009        assert!(result.is_err());
1010        assert!(matches!(result.unwrap_err(), NovaError::Near(_)));
1011    }
1012
1013    #[tokio::test]
1014    async fn test_is_authorized() {
1015        let sdk = make_sdk(TEST_ACCOUNT_ID).unwrap();
1016        let result = sdk.is_authorized("test_group", Some("random.user.testnet")).await;
1017        // Should error on non-existent group or return false for unauthorized
1018        match result {
1019            Ok(authorized) => assert!(!authorized, "Random user should not be authorized"),
1020            Err(e) => {
1021                // Accept any Near error (includes network errors, contract errors, etc.)
1022                assert!(matches!(e, NovaError::Near(_)), "Expected Near error, got: {:?}", e);
1023                // Network errors are acceptable in unit tests (RPC may be unavailable)
1024            }
1025        }
1026    }
1027
1028    #[tokio::test]
1029    async fn test_is_authorized_default_user() {
1030        let sdk = make_sdk(TEST_ACCOUNT_ID).unwrap();
1031        // Uses sdk.account_id by default
1032        let result = sdk.is_authorized("test_group", None).await;
1033        // Result depends on whether test_group exists and user is authorized
1034        assert!(result.is_ok() || matches!(result.unwrap_err(), NovaError::Near(_)));
1035    }
1036
1037    #[tokio::test]
1038    async fn test_get_group_checksum() {
1039        let sdk = make_sdk(TEST_ACCOUNT_ID).unwrap();
1040        let result = sdk.get_group_checksum("test_group").await;
1041        match result {
1042            Ok(checksum) => {
1043                // May be None if not set, or Some(string)
1044                if let Some(cs) = checksum {
1045                    assert!(!cs.is_empty());
1046                }
1047            }
1048            Err(e) => {
1049                // Group may not exist
1050                assert!(matches!(e, NovaError::Near(_)));
1051            }
1052        }
1053    }
1054
1055    #[tokio::test]
1056    async fn test_get_group_owner() {
1057        let sdk = make_sdk(TEST_ACCOUNT_ID).unwrap();
1058        let result = sdk.get_group_owner("test_group").await;
1059        match result {
1060            Ok(owner) => {
1061                if let Some(o) = owner {
1062                    assert!(!o.is_empty());
1063                    assert!(o.contains(".testnet") || o.contains(".near"));
1064                }
1065            }
1066            Err(e) => {
1067                assert!(matches!(e, NovaError::Near(_)));
1068            }
1069        }
1070    }
1071
1072    #[tokio::test]
1073    async fn test_get_group_owner_nonexistent() {
1074        let sdk = make_sdk(TEST_ACCOUNT_ID).unwrap();
1075        let result = sdk.get_group_owner("nonexistent_group_xyz_123").await;
1076        match result {
1077            Ok(owner) => assert!(owner.is_none(), "Nonexistent group should have no owner"),
1078            Err(_) => {} // Also acceptable
1079        }
1080    }
1081
1082    #[tokio::test]
1083    async fn test_estimate_fee() {
1084        let sdk = make_sdk(TEST_ACCOUNT_ID).unwrap();
1085        let fee = sdk.estimate_fee("claim_token").await.unwrap();
1086        assert!(fee > 0, "Fee should be positive");
1087        // Default claim_token fee is typically 0.001 NEAR = 1e21 yoctoNEAR
1088        println!("claim_token fee: {} yoctoNEAR ({} NEAR)", fee, fee as f64 / 1e24);
1089    }
1090
1091    #[tokio::test]
1092    async fn test_estimate_fee_record_transaction() {
1093        let sdk = make_sdk(TEST_ACCOUNT_ID).unwrap();
1094        let fee = sdk.estimate_fee("record_transaction").await.unwrap();
1095        assert!(fee > 0, "Record transaction fee should be positive");
1096        println!("record_transaction fee: {} yoctoNEAR ({} NEAR)", fee, fee as f64 / 1e24);
1097    }
1098
1099    #[tokio::test]
1100    async fn test_estimate_fee_unknown_action() {
1101        let sdk = make_sdk(TEST_ACCOUNT_ID).unwrap();
1102        let fee = sdk.estimate_fee("nonexistent_action").await.unwrap();
1103        assert_eq!(fee, 0, "Unknown action should return 0");
1104    }
1105
1106    #[tokio::test]
1107    async fn test_get_transactions_for_group() {
1108        let sdk = make_sdk(TEST_ACCOUNT_ID).unwrap();
1109        let result = sdk.get_transactions_for_group("test_group", Some("random.user.testnet")).await;
1110        match result {
1111            Ok(txs) => {
1112                // May be empty for random user
1113                println!("Found {} transactions", txs.len());
1114            }
1115            Err(e) => {
1116                assert!(matches!(e, NovaError::Near(_)));
1117            }
1118        }
1119    }
1120
1121    #[tokio::test]
1122    async fn test_get_transactions_for_group_default_user() {
1123        let sdk = make_sdk(TEST_ACCOUNT_ID).unwrap();
1124        let result = sdk.get_transactions_for_group("test_group", None).await;
1125        // Uses sdk.account_id by default
1126        assert!(result.is_ok() || matches!(result.unwrap_err(), NovaError::Near(_)));
1127    }
1128
1129    #[tokio::test]
1130    async fn test_view_invalid_group() {
1131        let sdk = make_sdk(TEST_ACCOUNT_ID).unwrap();
1132        let result = sdk.is_authorized("nonexistent_group_123", Some("test.user.testnet")).await;
1133        // Should error for non-existent group
1134        match result {
1135            Ok(_) => {} // Contract might return false instead of error
1136            Err(e) => assert!(matches!(e, NovaError::Near(_))),
1137        }
1138    }
1139
1140    // =========================================================================
1141    // MCP Tool Tests (Require Valid Session Token)
1142    // =========================================================================
1143
1144    #[tokio::test]
1145    async fn test_auth_status_invalid_token() {
1146        // SDK without api_key will fail at token fetch stage with Token error
1147        let result = NovaSdk::new(TEST_ACCOUNT_ID);
1148        assert!(result.is_ok());
1149        let sdk = result.unwrap();
1150        let auth_result = sdk.auth_status(None).await;
1151        assert!(auth_result.is_err());
1152        let err = auth_result.unwrap_err();
1153        // No api_key → Token error; invalid api_key → Token or Http error
1154        assert!(
1155            matches!(err, NovaError::Token(_))
1156            || matches!(err, NovaError::Mcp(_))
1157            || matches!(err, NovaError::Http(_))
1158            || matches!(err, NovaError::Auth(_)),
1159            "Expected Auth/Token/Mcp/Http error, got: {:?}", err
1160        );
1161    }
1162
1163    #[tokio::test]
1164    async fn test_register_group_invalid_token() {
1165        let sdk = make_sdk(TEST_ACCOUNT_ID).unwrap();
1166        let result = sdk.register_group("test_group_new").await;
1167        assert!(result.is_err());
1168    }
1169
1170    #[tokio::test]
1171    async fn test_add_group_member_invalid_token() {
1172        let sdk = make_sdk(TEST_ACCOUNT_ID).unwrap();
1173        let result = sdk.add_group_member("test_group", "new.member.testnet").await;
1174        assert!(result.is_err());
1175    }
1176
1177    #[tokio::test]
1178    async fn test_revoke_group_member_invalid_token() {
1179        let sdk = make_sdk(TEST_ACCOUNT_ID).unwrap();
1180        let result = sdk.revoke_group_member("test_group", "member.testnet").await;
1181        assert!(result.is_err());
1182    }
1183
1184    #[tokio::test]
1185    async fn test_composite_upload_invalid_token() {
1186        let sdk = make_sdk(TEST_ACCOUNT_ID).unwrap();
1187        let test_data = b"test data";
1188        let result: Result<crate::UploadResult, _> = sdk.upload("test_group", test_data, "test.txt").await;
1189        assert!(result.is_err());
1190    }
1191
1192    #[tokio::test]
1193    async fn test_retrieve_invalid_token() {
1194        let sdk = make_sdk(TEST_ACCOUNT_ID).unwrap();
1195        let result: Result<crate::RetrieveResult, _> = sdk.retrieve("test_group", "QmDummyCID123456789").await;
1196        assert!(result.is_err());
1197    }
1198
1199    #[tokio::test]
1200    async fn test_retrieve_invalid_cid() {
1201        let sdk = make_sdk(TEST_ACCOUNT_ID).unwrap();
1202        let result: Result<crate::RetrieveResult, _> = sdk.retrieve("test_group", "invalid_cid").await;
1203        assert!(result.is_err());
1204        let err = result.unwrap_err();
1205        assert!(matches!(err, NovaError::InvalidCid(_)));
1206        assert!(err.to_string().contains("invalid_cid"));
1207    }
1208
1209    #[tokio::test]
1210    async fn test_retrieve_empty_cid() {
1211        let sdk = make_sdk(TEST_ACCOUNT_ID).unwrap();
1212        let result: Result<crate::RetrieveResult, _> = sdk.retrieve("test_group", "").await;
1213        assert!(result.is_err());
1214        assert!(matches!(result.unwrap_err(), NovaError::InvalidCid(_)));
1215    }
1216
1217    // =========================================================================
1218    // Integration Tests (Require Real Credentials)
1219    // =========================================================================
1220
1221    fn get_integration_sdk() -> Option<NovaSdk> {
1222        let account_id = env::var("TEST_NOVA_ACCOUNT_ID").ok()?;
1223        let api_key = env::var("NOVA_API_KEY").ok()?;
1224        let config = NovaSdkConfig::default().with_api_key(&api_key);
1225        NovaSdk::with_config(&account_id, config).ok()
1226    }
1227
1228    #[tokio::test]
1229    async fn test_auth_status_integration() {
1230        let sdk = match get_integration_sdk() {
1231            Some(s) => s,
1232            None => {
1233                println!("Skipping: TEST_NOVA_ACCOUNT_ID and TEST_SESSION_TOKEN required");
1234                return;
1235            }
1236        };
1237
1238        let result = sdk.auth_status(Some("test_group")).await.unwrap();
1239        println!("Auth status: authenticated={}, account={:?}", 
1240                 result.authenticated, result.near_account_id);
1241        assert!(result.authenticated);
1242        assert!(result.near_account_id.is_some());
1243    }
1244
1245    #[tokio::test]
1246    async fn test_register_group_integration() {
1247        let sdk = match get_integration_sdk() {
1248            Some(s) => s,
1249            None => {
1250                println!("Skipping: TEST_NOVA_ACCOUNT_ID and TEST_SESSION_TOKEN required");
1251                return;
1252            }
1253        };
1254
1255        let group_id = format!("test_group_{}", std::time::SystemTime::now()
1256            .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs());
1257        
1258        let result = sdk.register_group(&group_id).await;
1259        match result {
1260            Ok(msg) => {
1261                println!("✅ Registered group: {}", msg);
1262                assert!(msg.contains(&group_id) || msg.contains("success"));
1263            }
1264            Err(e) => {
1265                // May fail if group exists or other issue
1266                println!("Register group result: {}", e);
1267            }
1268        }
1269    }
1270
1271    #[tokio::test]
1272    async fn test_register_group_existing_integration() {
1273        let sdk = match get_integration_sdk() {
1274            Some(s) => s,
1275            None => {
1276                println!("Skipping: TEST_NOVA_ACCOUNT_ID and TEST_SESSION_TOKEN required");
1277                return;
1278            }
1279        };
1280
1281        // Try to register existing group - should fail
1282        let result = sdk.register_group("test_group").await;
1283        if let Err(e) = result {
1284            assert!(matches!(e, NovaError::Mcp(_)));
1285            println!("Expected error for existing group: {}", e);
1286        }
1287    }
1288
1289    #[tokio::test]
1290    async fn test_add_group_member_integration() {
1291        let sdk = match get_integration_sdk() {
1292            Some(s) => s,
1293            None => {
1294                println!("Skipping: TEST_NOVA_ACCOUNT_ID and TEST_SESSION_TOKEN required");
1295                return;
1296            }
1297        };
1298
1299        let result = sdk.add_group_member("test_group", "new.member.testnet").await;
1300        match result {
1301            Ok(msg) => println!("✅ Added member: {}", msg),
1302            Err(e) => {
1303                if e.to_string().contains("already a member") {
1304                    println!("Already member - expected");
1305                } else {
1306                    println!("Add member error: {}", e);
1307                }
1308            }
1309        }
1310    }
1311
1312    #[tokio::test]
1313    async fn test_revoke_group_member_invalid_user_integration() {
1314        let sdk = match get_integration_sdk() {
1315            Some(s) => s,
1316            None => {
1317                println!("Skipping: TEST_NOVA_ACCOUNT_ID and TEST_SESSION_TOKEN required");
1318                return;
1319            }
1320        };
1321
1322        let result = sdk.revoke_group_member("test_group", "non.member.testnet").await;
1323        assert!(result.is_err());
1324        println!("Expected error for non-member: {}", result.unwrap_err());
1325    }
1326
1327    #[tokio::test]
1328    async fn test_composite_upload_integration() {
1329        let sdk = match get_integration_sdk() {
1330            Some(s) => s,
1331            None => {
1332                println!("Skipping: TEST_NOVA_ACCOUNT_ID and TEST_SESSION_TOKEN required");
1333                return;
1334            }
1335        };
1336
1337        let test_data = b"Test data for composite upload via MCP";
1338        let result = sdk.upload("test_group", test_data, "test.txt").await.unwrap();
1339        
1340        println!("✅ Upload success: cid={}, hash={}", result.cid, result.file_hash);
1341
1342        assert!(!result.cid.is_empty());
1343        assert!(result.cid.starts_with("Qm"));
1344        assert!(!result.trans_id.is_empty());
1345        assert_eq!(result.file_hash.len(), 64);
1346    }
1347
1348    #[tokio::test]
1349    async fn test_retrieve_integration() {
1350        let sdk = match get_integration_sdk() {
1351            Some(s) => s,
1352            None => {
1353                println!("Skipping: TEST_NOVA_ACCOUNT_ID and TEST_SESSION_TOKEN required");
1354                return;
1355            }
1356        };
1357
1358        // First upload
1359        let original_data = b"Test data for composite retrieve via MCP";
1360        let upload_result = sdk.upload("test_group", original_data, "retrieve_test.txt").await.unwrap();
1361        let cid = &upload_result.cid;
1362        
1363        // Then retrieve
1364        let retrieve_result = sdk.retrieve("test_group", cid).await.unwrap();
1365        
1366        println!("✅ Retrieve success:");
1367        println!("   Data length: {} bytes", retrieve_result.data.len());
1368        println!("   IPFS Hash: {}", retrieve_result.ipfs_hash);
1369        println!("   Group ID: {}", retrieve_result.group_id);
1370
1371        assert_eq!(retrieve_result.data, original_data);
1372        assert_eq!(retrieve_result.ipfs_hash, *cid);
1373        assert_eq!(retrieve_result.group_id, "test_group");
1374        
1375        println!("✅ Decrypted data matches original ({} bytes)", retrieve_result.data.len());
1376    }
1377
1378    #[tokio::test]
1379    async fn test_composite_upload_fee_breakdown_integration() {
1380        let sdk = match get_integration_sdk() {
1381            Some(s) => s,
1382            None => {
1383                println!("Skipping: TEST_NOVA_ACCOUNT_ID and TEST_SESSION_TOKEN required");
1384                return;
1385            }
1386        };
1387
1388        let test_data = b"Test data for fee breakdown";
1389        let result = sdk.upload("test_group", test_data, "test.txt").await.unwrap();
1390        
1391        assert!(!result.cid.is_empty(), "CID should not be empty");
1392        assert_eq!(result.file_hash.len(), 64, "File hash should be 64 hex chars");
1393        println!("✅ Upload fee breakdown test: cid={}, hash={}", result.cid, result.file_hash);
1394    }
1395
1396    #[tokio::test]
1397    async fn test_retrieve_fee_breakdown_integration() {
1398        let sdk = match get_integration_sdk() {
1399            Some(s) => s,
1400            None => {
1401                println!("Skipping: TEST_NOVA_ACCOUNT_ID and TEST_SESSION_TOKEN required");
1402                return;
1403            }
1404        };
1405
1406        let original_data = b"Test data for retrieve fee breakdown";
1407        let upload_result = sdk.upload("test_group", original_data, "fee_retrieve_test.txt").await.unwrap();
1408        let cid = &upload_result.cid;
1409        
1410        let retrieve_result = sdk.retrieve("test_group", cid).await.unwrap();
1411        println!("✅ Retrieve success: {} bytes", retrieve_result.data.len());
1412        assert_eq!(retrieve_result.data, original_data);
1413        assert_eq!(retrieve_result.group_id, "test_group");
1414    }
1415
1416    #[tokio::test]
1417    async fn test_get_transactions_for_group_integration() {
1418        let sdk = match get_integration_sdk() {
1419            Some(s) => s,
1420            None => {
1421                println!("Skipping: TEST_NOVA_ACCOUNT_ID and TEST_SESSION_TOKEN required");
1422                return;
1423            }
1424        };
1425
1426        let txs = sdk.get_transactions_for_group("test_group", None).await.unwrap();
1427        println!("Retrieved {} transactions for group", txs.len());
1428        
1429        if !txs.is_empty() {
1430            let tx = &txs[0];
1431            assert!(!tx.ipfs_hash.is_empty());
1432            assert!(!tx.file_hash.is_empty());
1433            assert!(!tx.group_id.is_empty());
1434            assert!(!tx.user_id.is_empty());
1435            println!("First tx: group={}, user={}, ipfs={}", 
1436                     tx.group_id, tx.user_id, tx.ipfs_hash);
1437        }
1438    }
1439
1440    #[tokio::test]
1441    async fn test_is_authorized_integration() {
1442        let sdk = match get_integration_sdk() {
1443            Some(s) => s,
1444            None => {
1445                println!("Skipping: TEST_NOVA_ACCOUNT_ID and TEST_SESSION_TOKEN required");
1446                return;
1447            }
1448        };
1449
1450        let authorized = sdk.is_authorized("test_group", None).await.unwrap();
1451        println!("User authorized for test_group: {}", authorized);
1452        // User should be authorized if they've used the group
1453    }
1454
1455    #[tokio::test]
1456    async fn test_get_group_owner_integration() {
1457        let sdk = match get_integration_sdk() {
1458            Some(s) => s,
1459            None => {
1460                println!("Skipping: TEST_NOVA_ACCOUNT_ID and TEST_SESSION_TOKEN required");
1461                return;
1462            }
1463        };
1464
1465        let owner = sdk.get_group_owner("test_group").await.unwrap();
1466        if let Some(o) = owner {
1467            println!("test_group owner: {}", o);
1468            assert!(o.contains(".testnet") || o.contains(".near"));
1469        } else {
1470            println!("test_group has no owner (may not exist)");
1471        }
1472    }
1473}