tally_sdk/
simple_client.rs

1//! Simple client for basic Tally SDK operations
2
3use crate::{
4    error::{Result, TallyError},
5    program_id_string,
6    program_types::{Merchant, Plan, Subscription},
7};
8use anchor_client::solana_account_decoder::UiAccountEncoding;
9use anchor_client::solana_client::rpc_client::GetConfirmedSignaturesForAddress2Config;
10use anchor_client::solana_client::rpc_client::RpcClient;
11use anchor_client::solana_client::rpc_config::{
12    RpcAccountInfoConfig, RpcProgramAccountsConfig, RpcTransactionConfig,
13};
14use anchor_client::solana_client::rpc_filter::{Memcmp, RpcFilterType};
15use anchor_client::solana_client::rpc_response::RpcConfirmedTransactionStatusWithSignature;
16use anchor_client::solana_sdk::commitment_config::CommitmentConfig;
17use anchor_client::solana_sdk::pubkey::Pubkey;
18use anchor_client::solana_sdk::{signature::Signer, transaction::Transaction};
19use anchor_lang::AnchorDeserialize;
20use std::str::FromStr;
21
22/// Simple Tally client for basic operations
23pub struct SimpleTallyClient {
24    /// RPC client for queries
25    pub rpc_client: RpcClient,
26    /// Program ID
27    pub program_id: Pubkey,
28}
29
30impl SimpleTallyClient {
31    /// Create a new simple Tally client
32    ///
33    /// # Arguments
34    /// * `cluster_url` - RPC endpoint URL
35    ///
36    /// # Returns
37    /// * `Ok(SimpleTallyClient)` - The client instance
38    ///
39    /// # Errors
40    /// Returns an error if the program ID cannot be parsed or client creation fails
41    pub fn new(cluster_url: &str) -> Result<Self> {
42        let rpc_client = RpcClient::new_with_commitment(cluster_url, CommitmentConfig::confirmed());
43        let program_id = Pubkey::from_str(&program_id_string())
44            .map_err(|e| TallyError::Generic(format!("Invalid program ID: {e}")))?;
45
46        Ok(Self {
47            rpc_client,
48            program_id,
49        })
50    }
51
52    /// Create a new simple Tally client with custom program ID
53    ///
54    /// # Arguments
55    /// * `cluster_url` - RPC endpoint URL
56    /// * `program_id` - Custom program ID to use
57    ///
58    /// # Returns
59    /// * `Ok(SimpleTallyClient)` - The client instance
60    ///
61    /// # Errors
62    /// Returns an error if the program ID cannot be parsed or client creation fails
63    pub fn new_with_program_id(cluster_url: &str, program_id: &str) -> Result<Self> {
64        let rpc_client = RpcClient::new_with_commitment(cluster_url, CommitmentConfig::confirmed());
65        let program_id = Pubkey::from_str(program_id)
66            .map_err(|e| TallyError::Generic(format!("Invalid program ID '{program_id}': {e}")))?;
67
68        Ok(Self {
69            rpc_client,
70            program_id,
71        })
72    }
73
74    /// Get the program ID
75    #[must_use]
76    pub const fn program_id(&self) -> Pubkey {
77        self.program_id
78    }
79
80    /// Compute merchant PDA using this client's program ID
81    pub fn merchant_address(&self, authority: &Pubkey) -> Pubkey {
82        crate::pda::merchant_address_with_program_id(authority, &self.program_id)
83    }
84
85    /// Get the RPC client
86    pub const fn rpc(&self) -> &RpcClient {
87        &self.rpc_client
88    }
89
90    /// Check if an account exists
91    ///
92    /// # Errors
93    /// Returns an error if the RPC call to check account existence fails
94    pub fn account_exists(&self, address: &Pubkey) -> Result<bool> {
95        // First try with confirmed commitment
96        match self
97            .rpc_client
98            .get_account_with_commitment(address, CommitmentConfig::confirmed())
99        {
100            Ok(response) => match response.value {
101                Some(_) => Ok(true),
102                None => Ok(false),
103            },
104            Err(e) => {
105                // If confirmed fails, try with processed commitment (more recent but less reliable)
106                match self
107                    .rpc_client
108                    .get_account_with_commitment(address, CommitmentConfig::processed())
109                {
110                    Ok(response) => match response.value {
111                        Some(_) => Ok(true),
112                        None => Ok(false),
113                    },
114                    Err(processed_err) => Err(TallyError::Generic(format!(
115                        "Failed to fetch account with both confirmed and processed commitment. Confirmed error: {e}, Processed error: {processed_err}"
116                    ))),
117                }
118            }
119        }
120    }
121
122    /// Get merchant account data
123    ///
124    /// # Errors
125    /// Returns an error if the account doesn't exist or can't be deserialized
126    pub fn get_merchant(&self, merchant_address: &Pubkey) -> Result<Option<Merchant>> {
127        let account_data = match self
128            .rpc_client
129            .get_account_with_commitment(merchant_address, CommitmentConfig::confirmed())
130            .map_err(|e| TallyError::Generic(format!("Failed to fetch merchant account: {e}")))?
131            .value
132        {
133            Some(account) => account.data,
134            None => return Ok(None),
135        };
136
137        if account_data.len() < 8 {
138            return Err(TallyError::Generic(
139                "Invalid merchant account data".to_string(),
140            ));
141        }
142
143        let merchant = Merchant::try_from_slice(&account_data[8..])
144            .map_err(|e| TallyError::Generic(format!("Failed to deserialize merchant: {e}")))?;
145
146        Ok(Some(merchant))
147    }
148
149    /// Get plan account data
150    ///
151    /// # Errors
152    /// Returns an error if the account doesn't exist or can't be deserialized
153    pub fn get_plan(&self, plan_address: &Pubkey) -> Result<Option<Plan>> {
154        let account_data = match self
155            .rpc_client
156            .get_account_with_commitment(plan_address, CommitmentConfig::confirmed())
157            .map_err(|e| TallyError::Generic(format!("Failed to fetch plan account: {e}")))?
158            .value
159        {
160            Some(account) => account.data,
161            None => return Ok(None),
162        };
163
164        if account_data.len() < 8 {
165            return Err(TallyError::Generic("Invalid plan account data".to_string()));
166        }
167
168        let plan = Plan::try_from_slice(&account_data[8..])
169            .map_err(|e| TallyError::Generic(format!("Failed to deserialize plan: {e}")))?;
170
171        Ok(Some(plan))
172    }
173
174    /// List all plans for a merchant
175    ///
176    /// # Errors
177    /// Returns an error if the RPC query fails or accounts can't be deserialized
178    pub fn list_plans(&self, merchant_address: &Pubkey) -> Result<Vec<(Pubkey, Plan)>> {
179        // Create filter to match merchant field in Plan account data
180        // Plan account layout: 8 bytes discriminator + Plan struct
181        // Plan struct: merchant (32 bytes) at offset 8
182        let filters = vec![
183            RpcFilterType::DataSize(129), // Filter by Plan account size
184            RpcFilterType::Memcmp(Memcmp::new_raw_bytes(
185                8,
186                merchant_address.to_bytes().to_vec(),
187            )),
188        ];
189
190        let config = RpcProgramAccountsConfig {
191            filters: Some(filters),
192            account_config: RpcAccountInfoConfig {
193                encoding: Some(UiAccountEncoding::Base64),
194                data_slice: None,
195                commitment: Some(CommitmentConfig::confirmed()),
196                min_context_slot: None,
197            },
198            with_context: Some(false),
199            sort_results: None,
200        };
201
202        let plan_accounts = self
203            .rpc_client
204            .get_program_accounts_with_config(&self.program_id, config)
205            .map_err(|e| TallyError::Generic(format!("Failed to query plan accounts: {e}")))?;
206
207        let mut plans = Vec::new();
208        for (pubkey, account) in plan_accounts {
209            if account.data.len() < 8 {
210                continue;
211            }
212
213            if let Ok(plan) = Plan::try_from_slice(&account.data[8..]) {
214                plans.push((pubkey, plan));
215            }
216            // Skip invalid accounts
217        }
218
219        Ok(plans)
220    }
221
222    /// List all subscriptions for a plan
223    ///
224    /// # Errors
225    /// Returns an error if the RPC query fails or accounts can't be deserialized
226    pub fn list_subscriptions(&self, plan_address: &Pubkey) -> Result<Vec<(Pubkey, Subscription)>> {
227        // Create filter to match plan field in Subscription account data
228        // Subscription account layout: 8 bytes discriminator + Subscription struct
229        // Subscription struct: plan (32 bytes) at offset 8
230        let filters = vec![
231            RpcFilterType::DataSize(105), // Filter by Subscription account size (8 + 32 + 32 + 8 + 8 + 8 + 8 + 1)
232            RpcFilterType::Memcmp(Memcmp::new_raw_bytes(8, plan_address.to_bytes().to_vec())),
233        ];
234
235        let config = RpcProgramAccountsConfig {
236            filters: Some(filters),
237            account_config: RpcAccountInfoConfig {
238                encoding: Some(UiAccountEncoding::Base64),
239                data_slice: None,
240                commitment: Some(CommitmentConfig::confirmed()),
241                min_context_slot: None,
242            },
243            with_context: Some(false),
244            sort_results: None,
245        };
246
247        let subscription_accounts = self
248            .rpc_client
249            .get_program_accounts_with_config(&self.program_id, config)
250            .map_err(|e| {
251                TallyError::Generic(format!("Failed to query subscription accounts: {e}"))
252            })?;
253
254        let mut subscriptions = Vec::new();
255        for (pubkey, account) in subscription_accounts {
256            if account.data.len() < 8 {
257                continue;
258            }
259
260            if let Ok(subscription) = Subscription::try_from_slice(&account.data[8..]) {
261                subscriptions.push((pubkey, subscription));
262            }
263            // Skip invalid accounts
264        }
265
266        Ok(subscriptions)
267    }
268
269    /// Submit and confirm a transaction
270    ///
271    /// # Errors
272    /// Returns an error if transaction submission or confirmation fails
273    pub fn submit_transaction<T: Signer>(
274        &self,
275        transaction: &mut Transaction,
276        signers: &[&T],
277    ) -> Result<String> {
278        // Get recent blockhash
279        let recent_blockhash = self
280            .rpc_client
281            .get_latest_blockhash_with_commitment(CommitmentConfig::confirmed())
282            .map_err(|e| TallyError::Generic(format!("Failed to get recent blockhash: {e}")))?
283            .0;
284
285        // Sign transaction
286        transaction.sign(signers, recent_blockhash);
287
288        // Submit and confirm transaction
289        let signature = self
290            .rpc_client
291            .send_and_confirm_transaction_with_spinner(transaction)
292            .map_err(|e| TallyError::Generic(format!("Transaction failed: {e}")))?;
293
294        Ok(signature.to_string())
295    }
296
297    /// Submit instruction with automatic transaction handling
298    ///
299    /// # Errors
300    /// Returns an error if transaction submission or confirmation fails
301    pub fn submit_instruction<T: Signer>(
302        &self,
303        instruction: anchor_client::solana_sdk::instruction::Instruction,
304        signers: &[&T],
305    ) -> Result<String> {
306        let payer = signers.first().ok_or("At least one signer is required")?;
307        let mut transaction = Transaction::new_with_payer(&[instruction], Some(&payer.pubkey()));
308        self.submit_transaction(&mut transaction, signers)
309    }
310
311    /// Get latest blockhash
312    ///
313    /// # Errors
314    /// Returns an error if RPC call fails
315    pub fn get_latest_blockhash(&self) -> Result<anchor_client::solana_sdk::hash::Hash> {
316        self.rpc_client
317            .get_latest_blockhash_with_commitment(CommitmentConfig::confirmed())
318            .map(|(hash, _slot)| hash)
319            .map_err(|e| TallyError::Generic(format!("Failed to get latest blockhash: {e}")))
320    }
321
322    /// Get latest blockhash with commitment
323    ///
324    /// # Errors
325    /// Returns an error if RPC call fails
326    pub fn get_latest_blockhash_with_commitment(
327        &self,
328        commitment: CommitmentConfig,
329    ) -> Result<(anchor_client::solana_sdk::hash::Hash, u64)> {
330        self.rpc_client
331            .get_latest_blockhash_with_commitment(commitment)
332            .map_err(|e| TallyError::Generic(format!("Failed to get latest blockhash: {e}")))
333    }
334
335    /// High-level method to create a merchant account
336    ///
337    /// # Errors
338    /// Returns an error if merchant creation fails
339    pub fn create_merchant<T: Signer>(
340        &self,
341        authority: &T,
342        usdc_mint: &Pubkey,
343        treasury_ata: &Pubkey,
344        platform_fee_bps: u16,
345    ) -> Result<(Pubkey, String)> {
346        // Validate parameters
347        crate::validation::validate_platform_fee_bps(platform_fee_bps)?;
348
349        // Check if merchant already exists
350        let merchant_pda = self.merchant_address(&authority.pubkey());
351        if self.account_exists(&merchant_pda)? {
352            return Err(TallyError::Generic(format!(
353                "Merchant account already exists at address: {merchant_pda}"
354            )));
355        }
356
357        // Build instruction using transaction builder with this client's program ID
358        let instruction = crate::transaction_builder::create_merchant()
359            .authority(authority.pubkey())
360            .usdc_mint(*usdc_mint)
361            .treasury_ata(*treasury_ata)
362            .platform_fee_bps(platform_fee_bps)
363            .program_id(self.program_id)
364            .build_instruction()?;
365
366        let signature = self.submit_instruction(instruction, &[authority])?;
367
368        Ok((merchant_pda, signature))
369    }
370
371    /// High-level method to initialize merchant with treasury management
372    ///
373    /// This method handles both cases:
374    /// - Treasury ATA exists + Merchant missing → Create merchant only
375    /// - Treasury ATA missing + Merchant missing → Create both ATA and merchant
376    ///
377    /// # Arguments
378    /// * `authority` - The wallet that will own the merchant account and treasury ATA
379    /// * `usdc_mint` - The USDC mint address
380    /// * `treasury_ata` - The expected treasury ATA address
381    /// * `platform_fee_bps` - Platform fee in basis points
382    ///
383    /// # Returns
384    /// * `Ok((merchant_pda, signature, created_ata))` - The merchant PDA, transaction signature, and whether ATA was created
385    /// * `Err(TallyError)` - If merchant already exists or other validation/execution failures
386    ///
387    /// # Errors
388    /// Returns an error if merchant already exists, validation fails, or transaction execution fails
389    pub fn initialize_merchant_with_treasury<T: Signer>(
390        &self,
391        authority: &T,
392        usdc_mint: &Pubkey,
393        treasury_ata: &Pubkey,
394        platform_fee_bps: u16,
395    ) -> Result<(Pubkey, String, bool)> {
396        use anchor_client::solana_sdk::transaction::Transaction;
397
398        // Validate parameters
399        crate::validation::validate_platform_fee_bps(platform_fee_bps)?;
400
401        // Check if merchant already exists
402        let merchant_pda = self.merchant_address(&authority.pubkey());
403        if self.account_exists(&merchant_pda)? {
404            return Err(TallyError::Generic(format!(
405                "Merchant account already exists at address: {merchant_pda}"
406            )));
407        }
408
409        // Check if treasury ATA exists
410        let treasury_exists =
411            crate::ata::get_token_account_info(self.rpc(), treasury_ata)?.is_some();
412
413        let mut instructions = Vec::new();
414        let created_ata = !treasury_exists;
415
416        // If treasury ATA doesn't exist, add create ATA instruction
417        if treasury_exists {
418            // Validate existing treasury ATA
419            crate::validation::validate_usdc_token_account(
420                self,
421                treasury_ata,
422                usdc_mint,
423                &authority.pubkey(),
424                "treasury",
425            )?;
426        } else {
427            // Validate the expected ATA address matches computed ATA
428            let computed_ata =
429                crate::ata::get_associated_token_address_for_mint(&authority.pubkey(), usdc_mint)?;
430            if computed_ata != *treasury_ata {
431                return Err(TallyError::Generic(format!(
432                    "Treasury ATA mismatch: expected {treasury_ata}, computed {computed_ata}"
433                )));
434            }
435
436            // Detect token program and create ATA instruction
437            let token_program = crate::ata::detect_token_program(self.rpc(), usdc_mint)?;
438            let create_ata_ix = crate::ata::create_associated_token_account_instruction(
439                &authority.pubkey(), // payer
440                &authority.pubkey(), // wallet owner
441                usdc_mint,
442                token_program,
443            )?;
444            instructions.push(create_ata_ix);
445        }
446
447        // Always add the create merchant instruction
448        let create_merchant_ix = crate::transaction_builder::create_merchant()
449            .authority(authority.pubkey())
450            .usdc_mint(*usdc_mint)
451            .treasury_ata(*treasury_ata)
452            .platform_fee_bps(platform_fee_bps)
453            .program_id(self.program_id)
454            .build_instruction()?;
455        instructions.push(create_merchant_ix);
456
457        // Submit transaction with all instructions
458        let mut transaction = Transaction::new_with_payer(&instructions, Some(&authority.pubkey()));
459        let signature = self.submit_transaction(&mut transaction, &[authority])?;
460
461        Ok((merchant_pda, signature, created_ata))
462    }
463
464    /// High-level method to create a subscription plan
465    ///
466    /// # Errors
467    /// Returns an error if plan creation fails
468    pub fn create_plan<T: Signer>(
469        &self,
470        authority: &T,
471        plan_args: crate::program_types::CreatePlanArgs,
472    ) -> Result<(Pubkey, String)> {
473        use crate::transaction_builder::create_plan;
474
475        // Validate plan parameters - ensure values can be safely cast to i64
476        let period_i64 = i64::try_from(plan_args.period_secs)
477            .map_err(|_| TallyError::Generic("Period seconds too large".to_string()))?;
478        let grace_i64 = i64::try_from(plan_args.grace_secs)
479            .map_err(|_| TallyError::Generic("Grace seconds too large".to_string()))?;
480
481        crate::validation::validate_plan_parameters(plan_args.price_usdc, period_i64, grace_i64)?;
482
483        // Validate merchant exists
484        let merchant_pda = self.merchant_address(&authority.pubkey());
485        if !self.account_exists(&merchant_pda)? {
486            return Err(TallyError::Generic(format!(
487                "Merchant account does not exist at address: {merchant_pda}"
488            )));
489        }
490
491        // Check if plan already exists
492        let plan_pda = crate::pda::plan_address_with_program_id(
493            &merchant_pda,
494            &plan_args.plan_id_bytes,
495            &self.program_id,
496        );
497        if self.account_exists(&plan_pda)? {
498            return Err(TallyError::Generic(format!(
499                "Plan already exists at address: {plan_pda}"
500            )));
501        }
502
503        let instruction = create_plan()
504            .authority(authority.pubkey())
505            .payer(authority.pubkey())
506            .plan_args(plan_args)
507            .program_id(self.program_id)
508            .build_instruction()?;
509
510        let signature = self.submit_instruction(instruction, &[authority])?;
511
512        Ok((plan_pda, signature))
513    }
514
515    /// High-level method to withdraw platform fees
516    ///
517    /// # Errors
518    /// Returns an error if fee withdrawal fails
519    pub fn withdraw_platform_fees<T: Signer>(
520        &self,
521        platform_authority: &T,
522        platform_treasury_ata: &Pubkey,
523        destination_ata: &Pubkey,
524        usdc_mint: &Pubkey,
525        amount: u64,
526    ) -> Result<String> {
527        use crate::transaction_builder::admin_withdraw_fees;
528
529        // Validate withdrawal amount
530        crate::validation::validate_withdrawal_amount(amount)?;
531
532        // Validate platform treasury ATA exists and has sufficient balance
533        let treasury_info = crate::ata::get_token_account_info(self.rpc(), platform_treasury_ata)?
534            .ok_or_else(|| {
535                TallyError::Generic(format!(
536                    "Platform treasury ATA {platform_treasury_ata} does not exist"
537                ))
538            })?;
539
540        let (treasury_account, _token_program) = treasury_info;
541        if treasury_account.amount < amount {
542            // Use integer division to avoid precision loss in error messages
543            let has_usdc = treasury_account.amount / 1_000_000;
544            let requested_usdc = amount / 1_000_000;
545            return Err(TallyError::Generic(format!(
546                "Insufficient balance in platform treasury: has {has_usdc} USDC, requested {requested_usdc} USDC"
547            )));
548        }
549
550        let instruction = admin_withdraw_fees()
551            .platform_authority(platform_authority.pubkey())
552            .platform_treasury_ata(*platform_treasury_ata)
553            .destination_ata(*destination_ata)
554            .usdc_mint(*usdc_mint)
555            .amount(amount)
556            .program_id(self.program_id)
557            .build_instruction()?;
558
559        self.submit_instruction(instruction, &[platform_authority])
560    }
561
562    /// Get confirmed signatures for a program address
563    ///
564    /// # Errors
565    /// Returns an error if RPC call fails
566    pub fn get_confirmed_signatures_for_address(
567        &self,
568        address: &Pubkey,
569        config: Option<GetConfirmedSignaturesForAddress2Config>,
570    ) -> Result<Vec<RpcConfirmedTransactionStatusWithSignature>> {
571        self.rpc_client
572            .get_signatures_for_address_with_config(address, config.unwrap_or_default())
573            .map_err(|e| {
574                TallyError::Generic(format!(
575                    "Failed to get signatures for address {address}: {e}"
576                ))
577            })
578    }
579
580    /// Get transaction details
581    ///
582    /// # Errors
583    /// Returns an error if RPC call fails or transaction not found
584    pub fn get_transaction(
585        &self,
586        signature: &anchor_client::solana_sdk::signature::Signature,
587    ) -> Result<serde_json::Value> {
588        self.rpc_client
589            .get_transaction_with_config(signature, RpcTransactionConfig::default())
590            .map(|tx| serde_json::to_value(tx).unwrap_or_default())
591            .map_err(|e| TallyError::Generic(format!("Failed to get transaction {signature}: {e}")))
592    }
593
594    /// Get multiple transactions in batch
595    ///
596    /// # Errors
597    /// Returns an error if any RPC calls fail
598    pub fn get_transactions(
599        &self,
600        signatures: &[anchor_client::solana_sdk::signature::Signature],
601    ) -> Result<Vec<Option<serde_json::Value>>> {
602        // Process transactions in chunks to avoid overwhelming the RPC
603        const CHUNK_SIZE: usize = 10;
604        let mut results = Vec::new();
605
606        for chunk in signatures.chunks(CHUNK_SIZE) {
607            for signature in chunk {
608                let transaction_result = self
609                    .rpc_client
610                    .get_transaction_with_config(signature, RpcTransactionConfig::default());
611                match transaction_result {
612                    Ok(tx) => results.push(Some(serde_json::to_value(tx).unwrap_or_default())),
613                    Err(_) => results.push(None), // Transaction not found or other error
614                }
615            }
616        }
617
618        Ok(results)
619    }
620
621    /// Submit and confirm a pre-signed transaction
622    ///
623    /// # Errors
624    /// Returns an error if transaction submission or confirmation fails
625    pub fn send_and_confirm_transaction(
626        &self,
627        transaction: &anchor_client::solana_sdk::transaction::VersionedTransaction,
628    ) -> Result<anchor_client::solana_sdk::signature::Signature> {
629        self.rpc_client
630            .send_and_confirm_transaction(transaction)
631            .map_err(|e| TallyError::Generic(format!("Transaction submission failed: {e}")))
632    }
633
634    /// Get current slot
635    ///
636    /// # Errors
637    /// Returns an error if RPC call fails
638    pub fn get_slot(&self) -> Result<u64> {
639        self.rpc_client
640            .get_slot()
641            .map_err(|e| TallyError::Generic(format!("Failed to get slot: {e}")))
642    }
643
644    /// Get health status
645    ///
646    /// # Errors
647    /// Returns an error if RPC call fails
648    pub fn get_health(&self) -> Result<()> {
649        self.rpc_client
650            .get_health()
651            .map_err(|e| TallyError::Generic(format!("Health check failed: {e}")))
652    }
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658
659    #[test]
660    fn test_simple_client_creation() {
661        let client = SimpleTallyClient::new("http://localhost:8899").unwrap();
662        assert_eq!(client.program_id().to_string(), program_id_string());
663    }
664}