1use 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
22pub struct SimpleTallyClient {
24 pub rpc_client: RpcClient,
26 pub program_id: Pubkey,
28}
29
30impl SimpleTallyClient {
31 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 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 #[must_use]
76 pub const fn program_id(&self) -> Pubkey {
77 self.program_id
78 }
79
80 pub fn merchant_address(&self, authority: &Pubkey) -> Pubkey {
82 crate::pda::merchant_address_with_program_id(authority, &self.program_id)
83 }
84
85 pub const fn rpc(&self) -> &RpcClient {
87 &self.rpc_client
88 }
89
90 pub fn account_exists(&self, address: &Pubkey) -> Result<bool> {
95 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 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 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 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 pub fn list_plans(&self, merchant_address: &Pubkey) -> Result<Vec<(Pubkey, Plan)>> {
179 let filters = vec![
183 RpcFilterType::DataSize(129), 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 }
218
219 Ok(plans)
220 }
221
222 pub fn list_subscriptions(&self, plan_address: &Pubkey) -> Result<Vec<(Pubkey, Subscription)>> {
227 let filters = vec![
231 RpcFilterType::DataSize(105), 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 }
265
266 Ok(subscriptions)
267 }
268
269 pub fn submit_transaction<T: Signer>(
274 &self,
275 transaction: &mut Transaction,
276 signers: &[&T],
277 ) -> Result<String> {
278 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 transaction.sign(signers, recent_blockhash);
287
288 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 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 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 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 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 crate::validation::validate_platform_fee_bps(platform_fee_bps)?;
348
349 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 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 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 crate::validation::validate_platform_fee_bps(platform_fee_bps)?;
400
401 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 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_exists {
418 crate::validation::validate_usdc_token_account(
420 self,
421 treasury_ata,
422 usdc_mint,
423 &authority.pubkey(),
424 "treasury",
425 )?;
426 } else {
427 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 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(), &authority.pubkey(), usdc_mint,
442 token_program,
443 )?;
444 instructions.push(create_ata_ix);
445 }
446
447 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 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 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 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 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 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 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 crate::validation::validate_withdrawal_amount(amount)?;
531
532 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 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 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 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 pub fn get_transactions(
599 &self,
600 signatures: &[anchor_client::solana_sdk::signature::Signature],
601 ) -> Result<Vec<Option<serde_json::Value>>> {
602 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), }
615 }
616 }
617
618 Ok(results)
619 }
620
621 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 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 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}