1use 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
20const 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#[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#[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#[derive(Debug, Clone)]
128struct TokenCache {
129 token: String,
130 expires_at: u64, }
132
133#[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 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 pub fn mainnet() -> Self {
169 Self::default()
170 }
171
172 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
179fn 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 let mut iv = [0u8; 12];
196 OsRng.fill_bytes(&mut iv);
197 let nonce = Nonce::from_slice(&iv);
198
199 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 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 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#[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 pub fn new(account_id: &str) -> Result<Self, NovaError> {
290 Self::with_config(account_id, NovaSdkConfig::default())
291 }
292
293 pub fn testnet(account_id: &str) -> Result<Self, NovaError> {
295 Self::with_config(account_id, NovaSdkConfig::testnet())
296 }
297
298 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 let network_id = Self::detect_network(&contract_id, &config.rpc_url);
312
313 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 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 async fn get_session_token(&self) -> Result<String, NovaError> {
345 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 {
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 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 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 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 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 let chars: Vec<char> = expires_in.chars().collect();
436 if chars.is_empty() {
437 return 23 * 60 * 60 * 1000; }
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 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 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 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 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 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 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 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) .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 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 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 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 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 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 pub async fn upload(
636 &self,
637 group_id: &str,
638 data: &[u8],
639 filename: &str,
640 ) -> Result<UploadResult, NovaError> {
641 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 let encrypted_b64 = encrypt_data(data, &key)?;
654
655 let file_hash = Self::compute_hash(data);
657
658 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 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 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 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 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 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 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 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 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 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 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 const MOCK_SESSION_TOKEN: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50X2lkIjoiYWxpY2Utbm92YS5ub3ZhLXNkay01LnRlc3RuZXQiLCJ0eXBlIjoibm92YV9zZXNzaW9uIn0.mock";
892 const TEST_ACCOUNT_ID: &str = "alice-nova.nova-sdk-6.testnet";
893
894 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 let result = NovaSdk::new(TEST_ACCOUNT_ID);
928 assert!(result.is_ok()); }
931
932 #[test]
937 fn test_compute_hash() {
938 let hash = NovaSdk::compute_hash(b"test data");
939 assert_eq!(hash.len(), 64); 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 assert_eq!(hash, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
964 }
965
966 #[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")); assert!(!"".starts_with("Qm"));
981 }
982
983 #[tokio::test]
988 async fn test_get_balance() {
989 let sdk = make_sdk(TEST_ACCOUNT_ID).unwrap();
990 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 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 match result {
1019 Ok(authorized) => assert!(!authorized, "Random user should not be authorized"),
1020 Err(e) => {
1021 assert!(matches!(e, NovaError::Near(_)), "Expected Near error, got: {:?}", e);
1023 }
1025 }
1026 }
1027
1028 #[tokio::test]
1029 async fn test_is_authorized_default_user() {
1030 let sdk = make_sdk(TEST_ACCOUNT_ID).unwrap();
1031 let result = sdk.is_authorized("test_group", None).await;
1033 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 if let Some(cs) = checksum {
1045 assert!(!cs.is_empty());
1046 }
1047 }
1048 Err(e) => {
1049 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(_) => {} }
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 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 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 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 match result {
1135 Ok(_) => {} Err(e) => assert!(matches!(e, NovaError::Near(_))),
1137 }
1138 }
1139
1140 #[tokio::test]
1145 async fn test_auth_status_invalid_token() {
1146 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 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 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 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 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 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 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 }
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}