use crate::{
error::{Result, TallyError},
program_id_string,
program_types::{Merchant, Plan, Subscription},
};
use anchor_client::solana_account_decoder::UiAccountEncoding;
use anchor_client::solana_client::rpc_client::GetConfirmedSignaturesForAddress2Config;
use anchor_client::solana_client::rpc_client::RpcClient;
use anchor_client::solana_client::rpc_config::{
RpcAccountInfoConfig, RpcProgramAccountsConfig, RpcTransactionConfig,
};
use anchor_client::solana_client::rpc_filter::{Memcmp, RpcFilterType};
use anchor_client::solana_client::rpc_response::RpcConfirmedTransactionStatusWithSignature;
use anchor_client::solana_sdk::commitment_config::CommitmentConfig;
use anchor_client::solana_sdk::pubkey::Pubkey;
use anchor_client::solana_sdk::{signature::Signer, transaction::Transaction};
use anchor_lang::AnchorDeserialize;
use std::str::FromStr;
pub struct SimpleTallyClient {
pub rpc_client: RpcClient,
pub program_id: Pubkey,
}
impl SimpleTallyClient {
pub fn new(cluster_url: &str) -> Result<Self> {
let rpc_client = RpcClient::new_with_commitment(cluster_url, CommitmentConfig::confirmed());
let program_id = Pubkey::from_str(&program_id_string())
.map_err(|e| TallyError::Generic(format!("Invalid program ID: {e}")))?;
Ok(Self {
rpc_client,
program_id,
})
}
pub fn new_with_program_id(cluster_url: &str, program_id: &str) -> Result<Self> {
let rpc_client = RpcClient::new_with_commitment(cluster_url, CommitmentConfig::confirmed());
let program_id = Pubkey::from_str(program_id)
.map_err(|e| TallyError::Generic(format!("Invalid program ID '{program_id}': {e}")))?;
Ok(Self {
rpc_client,
program_id,
})
}
#[must_use]
pub const fn program_id(&self) -> Pubkey {
self.program_id
}
pub fn merchant_address(&self, authority: &Pubkey) -> Pubkey {
crate::pda::merchant_address_with_program_id(authority, &self.program_id)
}
pub const fn rpc(&self) -> &RpcClient {
&self.rpc_client
}
pub fn account_exists(&self, address: &Pubkey) -> Result<bool> {
match self
.rpc_client
.get_account_with_commitment(address, CommitmentConfig::confirmed())
{
Ok(response) => match response.value {
Some(_) => Ok(true),
None => Ok(false),
},
Err(e) => {
match self
.rpc_client
.get_account_with_commitment(address, CommitmentConfig::processed())
{
Ok(response) => match response.value {
Some(_) => Ok(true),
None => Ok(false),
},
Err(processed_err) => Err(TallyError::Generic(format!(
"Failed to fetch account with both confirmed and processed commitment. Confirmed error: {e}, Processed error: {processed_err}"
))),
}
}
}
}
pub fn get_merchant(&self, merchant_address: &Pubkey) -> Result<Option<Merchant>> {
let account_data = match self
.rpc_client
.get_account_with_commitment(merchant_address, CommitmentConfig::confirmed())
.map_err(|e| TallyError::Generic(format!("Failed to fetch merchant account: {e}")))?
.value
{
Some(account) => account.data,
None => return Ok(None),
};
if account_data.len() < 8 {
return Err(TallyError::Generic(
"Invalid merchant account data".to_string(),
));
}
let merchant = Merchant::try_from_slice(&account_data[8..])
.map_err(|e| TallyError::Generic(format!("Failed to deserialize merchant: {e}")))?;
Ok(Some(merchant))
}
pub fn get_plan(&self, plan_address: &Pubkey) -> Result<Option<Plan>> {
let account_data = match self
.rpc_client
.get_account_with_commitment(plan_address, CommitmentConfig::confirmed())
.map_err(|e| TallyError::Generic(format!("Failed to fetch plan account: {e}")))?
.value
{
Some(account) => account.data,
None => return Ok(None),
};
if account_data.len() < 8 {
return Err(TallyError::Generic("Invalid plan account data".to_string()));
}
let plan = Plan::try_from_slice(&account_data[8..])
.map_err(|e| TallyError::Generic(format!("Failed to deserialize plan: {e}")))?;
Ok(Some(plan))
}
pub fn list_plans(&self, merchant_address: &Pubkey) -> Result<Vec<(Pubkey, Plan)>> {
let filters = vec![
RpcFilterType::DataSize(129), RpcFilterType::Memcmp(Memcmp::new_raw_bytes(
8,
merchant_address.to_bytes().to_vec(),
)),
];
let config = RpcProgramAccountsConfig {
filters: Some(filters),
account_config: RpcAccountInfoConfig {
encoding: Some(UiAccountEncoding::Base64),
data_slice: None,
commitment: Some(CommitmentConfig::confirmed()),
min_context_slot: None,
},
with_context: Some(false),
sort_results: None,
};
let plan_accounts = self
.rpc_client
.get_program_accounts_with_config(&self.program_id, config)
.map_err(|e| TallyError::Generic(format!("Failed to query plan accounts: {e}")))?;
let mut plans = Vec::new();
for (pubkey, account) in plan_accounts {
if account.data.len() < 8 {
continue;
}
if let Ok(plan) = Plan::try_from_slice(&account.data[8..]) {
plans.push((pubkey, plan));
}
}
Ok(plans)
}
pub fn list_subscriptions(&self, plan_address: &Pubkey) -> Result<Vec<(Pubkey, Subscription)>> {
let filters = vec![
RpcFilterType::DataSize(105), RpcFilterType::Memcmp(Memcmp::new_raw_bytes(8, plan_address.to_bytes().to_vec())),
];
let config = RpcProgramAccountsConfig {
filters: Some(filters),
account_config: RpcAccountInfoConfig {
encoding: Some(UiAccountEncoding::Base64),
data_slice: None,
commitment: Some(CommitmentConfig::confirmed()),
min_context_slot: None,
},
with_context: Some(false),
sort_results: None,
};
let subscription_accounts = self
.rpc_client
.get_program_accounts_with_config(&self.program_id, config)
.map_err(|e| {
TallyError::Generic(format!("Failed to query subscription accounts: {e}"))
})?;
let mut subscriptions = Vec::new();
for (pubkey, account) in subscription_accounts {
if account.data.len() < 8 {
continue;
}
if let Ok(subscription) = Subscription::try_from_slice(&account.data[8..]) {
subscriptions.push((pubkey, subscription));
}
}
Ok(subscriptions)
}
pub fn submit_transaction<T: Signer>(
&self,
transaction: &mut Transaction,
signers: &[&T],
) -> Result<String> {
let recent_blockhash = self
.rpc_client
.get_latest_blockhash_with_commitment(CommitmentConfig::confirmed())
.map_err(|e| TallyError::Generic(format!("Failed to get recent blockhash: {e}")))?
.0;
transaction.sign(signers, recent_blockhash);
let signature = self
.rpc_client
.send_and_confirm_transaction_with_spinner(transaction)
.map_err(|e| TallyError::Generic(format!("Transaction failed: {e}")))?;
Ok(signature.to_string())
}
pub fn submit_instruction<T: Signer>(
&self,
instruction: anchor_client::solana_sdk::instruction::Instruction,
signers: &[&T],
) -> Result<String> {
let payer = signers.first().ok_or("At least one signer is required")?;
let mut transaction = Transaction::new_with_payer(&[instruction], Some(&payer.pubkey()));
self.submit_transaction(&mut transaction, signers)
}
pub fn get_latest_blockhash(&self) -> Result<anchor_client::solana_sdk::hash::Hash> {
self.rpc_client
.get_latest_blockhash_with_commitment(CommitmentConfig::confirmed())
.map(|(hash, _slot)| hash)
.map_err(|e| TallyError::Generic(format!("Failed to get latest blockhash: {e}")))
}
pub fn get_latest_blockhash_with_commitment(
&self,
commitment: CommitmentConfig,
) -> Result<(anchor_client::solana_sdk::hash::Hash, u64)> {
self.rpc_client
.get_latest_blockhash_with_commitment(commitment)
.map_err(|e| TallyError::Generic(format!("Failed to get latest blockhash: {e}")))
}
pub fn create_merchant<T: Signer>(
&self,
authority: &T,
usdc_mint: &Pubkey,
treasury_ata: &Pubkey,
platform_fee_bps: u16,
) -> Result<(Pubkey, String)> {
crate::validation::validate_platform_fee_bps(platform_fee_bps)?;
let merchant_pda = self.merchant_address(&authority.pubkey());
if self.account_exists(&merchant_pda)? {
return Err(TallyError::Generic(format!(
"Merchant account already exists at address: {merchant_pda}"
)));
}
let instruction = crate::transaction_builder::create_merchant()
.authority(authority.pubkey())
.usdc_mint(*usdc_mint)
.treasury_ata(*treasury_ata)
.platform_fee_bps(platform_fee_bps)
.program_id(self.program_id)
.build_instruction()?;
let signature = self.submit_instruction(instruction, &[authority])?;
Ok((merchant_pda, signature))
}
pub fn initialize_merchant_with_treasury<T: Signer>(
&self,
authority: &T,
usdc_mint: &Pubkey,
treasury_ata: &Pubkey,
platform_fee_bps: u16,
) -> Result<(Pubkey, String, bool)> {
use anchor_client::solana_sdk::transaction::Transaction;
crate::validation::validate_platform_fee_bps(platform_fee_bps)?;
let merchant_pda = self.merchant_address(&authority.pubkey());
if self.account_exists(&merchant_pda)? {
return Err(TallyError::Generic(format!(
"Merchant account already exists at address: {merchant_pda}"
)));
}
let treasury_exists =
crate::ata::get_token_account_info(self.rpc(), treasury_ata)?.is_some();
let mut instructions = Vec::new();
let created_ata = !treasury_exists;
if treasury_exists {
crate::validation::validate_usdc_token_account(
self,
treasury_ata,
usdc_mint,
&authority.pubkey(),
"treasury",
)?;
} else {
let computed_ata =
crate::ata::get_associated_token_address_for_mint(&authority.pubkey(), usdc_mint)?;
if computed_ata != *treasury_ata {
return Err(TallyError::Generic(format!(
"Treasury ATA mismatch: expected {treasury_ata}, computed {computed_ata}"
)));
}
let token_program = crate::ata::detect_token_program(self.rpc(), usdc_mint)?;
let create_ata_ix = crate::ata::create_associated_token_account_instruction(
&authority.pubkey(), &authority.pubkey(), usdc_mint,
token_program,
)?;
instructions.push(create_ata_ix);
}
let create_merchant_ix = crate::transaction_builder::create_merchant()
.authority(authority.pubkey())
.usdc_mint(*usdc_mint)
.treasury_ata(*treasury_ata)
.platform_fee_bps(platform_fee_bps)
.program_id(self.program_id)
.build_instruction()?;
instructions.push(create_merchant_ix);
let mut transaction = Transaction::new_with_payer(&instructions, Some(&authority.pubkey()));
let signature = self.submit_transaction(&mut transaction, &[authority])?;
Ok((merchant_pda, signature, created_ata))
}
pub fn create_plan<T: Signer>(
&self,
authority: &T,
plan_args: crate::program_types::CreatePlanArgs,
) -> Result<(Pubkey, String)> {
use crate::transaction_builder::create_plan;
let period_i64 = i64::try_from(plan_args.period_secs)
.map_err(|_| TallyError::Generic("Period seconds too large".to_string()))?;
let grace_i64 = i64::try_from(plan_args.grace_secs)
.map_err(|_| TallyError::Generic("Grace seconds too large".to_string()))?;
crate::validation::validate_plan_parameters(plan_args.price_usdc, period_i64, grace_i64)?;
let merchant_pda = self.merchant_address(&authority.pubkey());
if !self.account_exists(&merchant_pda)? {
return Err(TallyError::Generic(format!(
"Merchant account does not exist at address: {merchant_pda}"
)));
}
let plan_pda = crate::pda::plan_address_with_program_id(
&merchant_pda,
&plan_args.plan_id_bytes,
&self.program_id,
);
if self.account_exists(&plan_pda)? {
return Err(TallyError::Generic(format!(
"Plan already exists at address: {plan_pda}"
)));
}
let instruction = create_plan()
.authority(authority.pubkey())
.payer(authority.pubkey())
.plan_args(plan_args)
.program_id(self.program_id)
.build_instruction()?;
let signature = self.submit_instruction(instruction, &[authority])?;
Ok((plan_pda, signature))
}
pub fn withdraw_platform_fees<T: Signer>(
&self,
platform_authority: &T,
platform_treasury_ata: &Pubkey,
destination_ata: &Pubkey,
usdc_mint: &Pubkey,
amount: u64,
) -> Result<String> {
use crate::transaction_builder::admin_withdraw_fees;
crate::validation::validate_withdrawal_amount(amount)?;
let treasury_info = crate::ata::get_token_account_info(self.rpc(), platform_treasury_ata)?
.ok_or_else(|| {
TallyError::Generic(format!(
"Platform treasury ATA {platform_treasury_ata} does not exist"
))
})?;
let (treasury_account, _token_program) = treasury_info;
if treasury_account.amount < amount {
let has_usdc = treasury_account.amount / 1_000_000;
let requested_usdc = amount / 1_000_000;
return Err(TallyError::Generic(format!(
"Insufficient balance in platform treasury: has {has_usdc} USDC, requested {requested_usdc} USDC"
)));
}
let instruction = admin_withdraw_fees()
.platform_authority(platform_authority.pubkey())
.platform_treasury_ata(*platform_treasury_ata)
.destination_ata(*destination_ata)
.usdc_mint(*usdc_mint)
.amount(amount)
.program_id(self.program_id)
.build_instruction()?;
self.submit_instruction(instruction, &[platform_authority])
}
pub fn get_confirmed_signatures_for_address(
&self,
address: &Pubkey,
config: Option<GetConfirmedSignaturesForAddress2Config>,
) -> Result<Vec<RpcConfirmedTransactionStatusWithSignature>> {
self.rpc_client
.get_signatures_for_address_with_config(address, config.unwrap_or_default())
.map_err(|e| {
TallyError::Generic(format!(
"Failed to get signatures for address {address}: {e}"
))
})
}
pub fn get_transaction(
&self,
signature: &anchor_client::solana_sdk::signature::Signature,
) -> Result<serde_json::Value> {
self.rpc_client
.get_transaction_with_config(signature, RpcTransactionConfig::default())
.map(|tx| serde_json::to_value(tx).unwrap_or_default())
.map_err(|e| TallyError::Generic(format!("Failed to get transaction {signature}: {e}")))
}
pub fn get_transactions(
&self,
signatures: &[anchor_client::solana_sdk::signature::Signature],
) -> Result<Vec<Option<serde_json::Value>>> {
const CHUNK_SIZE: usize = 10;
let mut results = Vec::new();
for chunk in signatures.chunks(CHUNK_SIZE) {
for signature in chunk {
let transaction_result = self
.rpc_client
.get_transaction_with_config(signature, RpcTransactionConfig::default());
match transaction_result {
Ok(tx) => results.push(Some(serde_json::to_value(tx).unwrap_or_default())),
Err(_) => results.push(None), }
}
}
Ok(results)
}
pub fn send_and_confirm_transaction(
&self,
transaction: &anchor_client::solana_sdk::transaction::VersionedTransaction,
) -> Result<anchor_client::solana_sdk::signature::Signature> {
self.rpc_client
.send_and_confirm_transaction(transaction)
.map_err(|e| TallyError::Generic(format!("Transaction submission failed: {e}")))
}
pub fn get_slot(&self) -> Result<u64> {
self.rpc_client
.get_slot()
.map_err(|e| TallyError::Generic(format!("Failed to get slot: {e}")))
}
pub fn get_health(&self) -> Result<()> {
self.rpc_client
.get_health()
.map_err(|e| TallyError::Generic(format!("Health check failed: {e}")))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple_client_creation() {
let client = SimpleTallyClient::new("http://localhost:8899").unwrap();
assert_eq!(client.program_id().to_string(), program_id_string());
}
}