use crate::error::{AppError, ErrorCode}; use borsh::{to_vec, BorshSerialize}; use sha2::{Digest, Sha256}; use solana_sdk::instruction::{AccountMeta, Instruction}; use solana_sdk::pubkey::Pubkey; pub const CARDCLI_FEE_VAULT_PROGRAM_ID: &str = "EpB6hUZUf1vvvTVAYvEN57pjUWfYswaAuKGGQDHP5iH"; pub const DEFAULT_FIXED_FEE_CENTS: u64 = 10; pub const DEFAULT_VARIABLE_FEE_BPS: u16 = 20; const FEE_VAULT_SEED: &[u8] = b"fee_vault"; #[derive(BorshSerialize)] struct InitializeArgs { fee_bps: u16, fixed_fee_usd_cents: u16, } #[derive(BorshSerialize)] struct CollectFeeArgs { card_amount_usd_cents: u64, fee_usd_cents: u64, fee_lamports: u64, fee_reference: [u8; 16], } pub fn card_fee_cents(card_amount_cents: u64, fixed_fee_cents: u64, fee_bps: u16) -> u64 { let variable_fee = (card_amount_cents * fee_bps as u64).div_ceil(10_000); fixed_fee_cents + variable_fee } pub fn usd_cents_to_lamports(fee_usd_cents: u64, sol_price_usd: &str) -> Result { let price_cents = parse_money_to_cents(sol_price_usd)? as u128; if price_cents == 0 { return Err(AppError::new( ErrorCode::Usage, "SOL spot price must be greater than zero", )); } let lamports = (fee_usd_cents as u128 * 1_000_000_000u128).div_ceil(price_cents) as u64; Ok(lamports.max(1)) } pub fn derive_treasury_vault_pda(program_id: &Pubkey) -> (Pubkey, u8) { Pubkey::find_program_address(&[FEE_VAULT_SEED], program_id) } pub fn initialize_fee_vault_instruction( program_id: &Pubkey, authority: &Pubkey, fee_bps: u16, fixed_fee_cents: u64, ) -> Result<(Instruction, Pubkey), AppError> { let (fee_vault, _bump) = derive_treasury_vault_pda(program_id); let fixed_fee_usd_cents = u16::try_from(fixed_fee_cents).map_err(|_| { AppError::new( ErrorCode::Usage, "fixed_fee_cents must fit into a u16 for Anchor initialization", ) })?; let args = InitializeArgs { fee_bps, fixed_fee_usd_cents, }; let mut data = anchor_discriminator("initialize").to_vec(); data.extend(to_vec(&args).map_err(|e| AppError::new(ErrorCode::General, e.to_string()))?); Ok(( Instruction { program_id: *program_id, accounts: vec![ AccountMeta::new(fee_vault, false), AccountMeta::new(*authority, true), AccountMeta::new_readonly( "11111111111111111111111111111111" .parse::() .expect("valid system program id"), false, ), ], data, }, fee_vault, )) } pub fn collect_fee_instruction( program_id: &Pubkey, payer: &Pubkey, card_amount_usd_cents: u64, fee_usd_cents: u64, fee_lamports: u64, fee_reference: [u8; 16], ) -> Result<(Instruction, Pubkey), AppError> { let (fee_vault, _bump) = derive_treasury_vault_pda(program_id); let args = CollectFeeArgs { card_amount_usd_cents, fee_usd_cents, fee_lamports, fee_reference, }; let mut data = anchor_discriminator("collect_fee").to_vec(); data.extend(to_vec(&args).map_err(|e| AppError::new(ErrorCode::General, e.to_string()))?); Ok(( Instruction { program_id: *program_id, accounts: vec![ AccountMeta::new(fee_vault, false), AccountMeta::new(*payer, true), AccountMeta::new_readonly( "11111111111111111111111111111111" .parse::() .expect("valid system program id"), false, ), ], data, }, fee_vault, )) } fn anchor_discriminator(name: &str) -> [u8; 8] { let mut hasher = Sha256::new(); hasher.update(format!("global:{name}").as_bytes()); let hash = hasher.finalize(); let mut out = [0u8; 8]; out.copy_from_slice(&hash[..8]); out } fn parse_money_to_cents(value: &str) -> Result { let parsed = value.trim().parse::().map_err(|_| { AppError::new( ErrorCode::Usage, format!("Invalid money value for SOL price: {value}"), ) })?; Ok((parsed * 100.0).round() as i64) } #[cfg(test)] mod tests { use super::*; #[test] fn hybrid_fee_formula_is_stable() { assert_eq!( card_fee_cents(1_000, DEFAULT_FIXED_FEE_CENTS, DEFAULT_VARIABLE_FEE_BPS), 12 ); } #[test] fn fee_lamports_rounds_up() { assert_eq!(usd_cents_to_lamports(12, "80.00").unwrap(), 1_500_000); } #[test] fn fee_vault_space_matches_anchor_account() { assert_eq!(8 + 61, 69); } #[test] fn collect_fee_discriminator_is_stable() { assert_eq!( anchor_discriminator("collect_fee"), [60, 173, 247, 103, 4, 93, 130, 48] ); } #[test] fn initialize_discriminator_is_stable() { assert_eq!( anchor_discriminator("initialize"), [175, 175, 109, 31, 13, 152, 155, 237] ); } }