Skip to main content

fly402_core/
payment_processor.rs

1use solana_client::rpc_client::RpcClient;
2use solana_sdk::{
3    commitment_config::CommitmentConfig,
4    instruction::Instruction,
5    message::Message,
6    pubkey::Pubkey,
7    signature::{Keypair, Signature, Signer},
8    transaction::Transaction,
9};
10use spl_token::instruction as token_instruction;
11use spl_associated_token_account::{
12    get_associated_token_address, instruction::create_associated_token_account,
13};
14use std::str::FromStr;
15
16use crate::{
17    errors::{X402Error, X402Result},
18    models::{PaymentAuthorization, PaymentRequest},
19};
20
21/// Solana payment processor for handling blockchain operations
22pub struct SolanaPaymentProcessor {
23    rpc_client: RpcClient,
24    #[allow(dead_code)]
25    commitment: CommitmentConfig,
26}
27
28impl SolanaPaymentProcessor {
29    /// Create a new Solana payment processor
30    ///
31    /// # Arguments
32    /// * `rpc_url` - Solana RPC endpoint URL
33    /// * `commitment` - Transaction commitment level (default: confirmed)
34    pub fn new(rpc_url: &str, commitment: Option<CommitmentConfig>) -> Self {
35        Self {
36            rpc_client: RpcClient::new_with_commitment(
37                rpc_url.to_string(),
38                commitment.unwrap_or(CommitmentConfig::confirmed()),
39            ),
40            commitment: commitment.unwrap_or(CommitmentConfig::confirmed()),
41        }
42    }
43
44    /// Get the default RPC URL for a network
45    pub fn default_rpc_url(network: &str) -> &'static str {
46        match network {
47            "solana-mainnet" => "https://api.mainnet-beta.solana.com",
48            "solana-devnet" => "https://api.devnet.solana.com",
49            "solana-testnet" => "https://api.testnet.solana.com",
50            _ => "https://api.devnet.solana.com",
51        }
52    }
53
54    /// Create a payment from a payment request
55    ///
56    /// This creates, signs, and broadcasts a Solana SPL token transfer transaction
57    pub async fn create_payment(
58        &self,
59        request: &PaymentRequest,
60        payer: &Keypair,
61    ) -> X402Result<PaymentAuthorization> {
62        // Check if payment has expired
63        if request.is_expired() {
64            return Err(X402Error::PaymentExpired(format!(
65                "Payment request expired at {}",
66                request.expires_at
67            )));
68        }
69
70        // Parse addresses
71        let token_mint = Pubkey::from_str(&request.asset_address).map_err(|e| {
72            X402Error::InvalidPaymentRequest(format!("Invalid token mint address: {}", e))
73        })?;
74
75        let recipient = Pubkey::from_str(&request.payment_address).map_err(|e| {
76            X402Error::InvalidPaymentRequest(format!("Invalid payment address: {}", e))
77        })?;
78
79        let amount = Self::parse_amount(&request.max_amount_required)?;
80
81        // Get or create associated token accounts
82        let sender_ata = get_associated_token_address(&payer.pubkey(), &token_mint);
83        let recipient_ata = get_associated_token_address(&recipient, &token_mint);
84
85        // Check sender balance
86        self.check_balance(&sender_ata, amount).await?;
87
88        // Build transaction
89        let mut instructions: Vec<Instruction> = Vec::new();
90
91        // Check if recipient ATA exists, if not create it
92        if !self.account_exists(&recipient_ata).await? {
93            instructions.push(create_associated_token_account(
94                &payer.pubkey(),
95                &recipient,
96                &token_mint,
97                &spl_token::id(),
98            ));
99        }
100
101        // Add transfer instruction
102        instructions.push(
103            token_instruction::transfer_checked(
104                &spl_token::id(),
105                &sender_ata,
106                &token_mint,
107                &recipient_ata,
108                &payer.pubkey(),
109                &[],
110                amount,
111                6, // USDC uses 6 decimals
112            )
113            .map_err(|e| {
114                X402Error::Blockchain(format!("Failed to create transfer instruction: {}", e))
115            })?,
116        );
117
118        // Get recent blockhash
119        let recent_blockhash = self
120            .rpc_client
121            .get_latest_blockhash()
122            .map_err(|e| X402Error::Network(format!("Failed to get recent blockhash: {}", e)))?;
123
124        // Create and sign transaction
125        let message = Message::new(&instructions, Some(&payer.pubkey()));
126        let mut transaction = Transaction::new_unsigned(message);
127        transaction.sign(&[payer], recent_blockhash);
128
129        // Send transaction
130        let signature = self
131            .rpc_client
132            .send_and_confirm_transaction(&transaction)
133            .map_err(|e| {
134                X402Error::TransactionBroadcast(format!("Failed to broadcast transaction: {}", e))
135            })?;
136
137        // Create payment authorization
138        Ok(PaymentAuthorization::new(
139            request.payment_id.clone(),
140            request.max_amount_required.clone(),
141            request.payment_address.clone(),
142            request.asset_address.clone(),
143            request.network.clone(),
144            signature.to_string(),
145            payer.pubkey().to_string(),
146        ))
147    }
148
149    /// Verify a payment transaction
150    ///
151    /// This checks that the transaction exists on-chain and matches the expected parameters
152    pub async fn verify_payment(
153        &self,
154        authorization: &PaymentAuthorization,
155        expected_amount: &str,
156    ) -> X402Result<bool> {
157        let signature = Signature::from_str(&authorization.signature).map_err(|e| {
158            X402Error::InvalidPaymentAuthorization(format!("Invalid signature: {}", e))
159        })?;
160
161        // Get transaction details
162        let transaction = self
163            .rpc_client
164            .get_transaction(&signature, solana_transaction_status::UiTransactionEncoding::Json)
165            .map_err(|e| {
166                X402Error::PaymentVerification(format!("Failed to fetch transaction: {}", e))
167            })?;
168
169        // Verify transaction succeeded
170        if transaction.transaction.meta.as_ref().and_then(|m| m.err.as_ref()).is_some() {
171            return Err(X402Error::PaymentVerification(
172                "Transaction failed on-chain".to_string(),
173            ));
174        }
175
176        // Parse and verify amount
177        let expected = Self::parse_amount(expected_amount)?;
178        let actual = Self::parse_amount(&authorization.actual_amount)?;
179
180        if actual < expected {
181            return Err(X402Error::PaymentVerification(format!(
182                "Payment amount {} is less than required {}",
183                authorization.actual_amount, expected_amount
184            )));
185        }
186
187        Ok(true)
188    }
189
190    /// Get token balance for an account
191    pub async fn get_token_balance(&self, token_account: &Pubkey) -> X402Result<u64> {
192        let balance = self
193            .rpc_client
194            .get_token_account_balance(token_account)
195            .map_err(|e| X402Error::Network(format!("Failed to get token balance: {}", e)))?;
196
197        balance
198            .amount
199            .parse::<u64>()
200            .map_err(|e| X402Error::Blockchain(format!("Failed to parse balance: {}", e)))
201    }
202
203    /// Check if an account exists
204    async fn account_exists(&self, account: &Pubkey) -> X402Result<bool> {
205        match self.rpc_client.get_account(account) {
206            Ok(_) => Ok(true),
207            Err(e) => {
208                // Account not found is not an error
209                if e.to_string().contains("AccountNotFound") {
210                    Ok(false)
211                } else {
212                    Err(X402Error::Network(format!(
213                        "Failed to check account existence: {}",
214                        e
215                    )))
216                }
217            }
218        }
219    }
220
221    /// Check if the sender has sufficient balance
222    async fn check_balance(&self, token_account: &Pubkey, required_amount: u64) -> X402Result<()> {
223        let balance = self.get_token_balance(token_account).await?;
224
225        if balance < required_amount {
226            return Err(X402Error::InsufficientFunds(format!(
227                "Insufficient balance: {} required, {} available",
228                required_amount, balance
229            )));
230        }
231
232        Ok(())
233    }
234
235    /// Parse amount string to lamports (assumes 6 decimals for USDC)
236    fn parse_amount(amount_str: &str) -> X402Result<u64> {
237        let amount: f64 = amount_str.parse().map_err(|e| {
238            X402Error::InvalidPaymentRequest(format!("Invalid amount format: {}", e))
239        })?;
240
241        // Convert to smallest unit (6 decimals for USDC)
242        let lamports = (amount * 1_000_000.0) as u64;
243        Ok(lamports)
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn test_parse_amount() {
253        assert_eq!(SolanaPaymentProcessor::parse_amount("0.10").unwrap(), 100_000);
254        assert_eq!(SolanaPaymentProcessor::parse_amount("1.0").unwrap(), 1_000_000);
255        assert_eq!(
256            SolanaPaymentProcessor::parse_amount("0.000001").unwrap(),
257            1
258        );
259    }
260
261    #[test]
262    fn test_default_rpc_url() {
263        assert_eq!(
264            SolanaPaymentProcessor::default_rpc_url("solana-mainnet"),
265            "https://api.mainnet-beta.solana.com"
266        );
267        assert_eq!(
268            SolanaPaymentProcessor::default_rpc_url("solana-devnet"),
269            "https://api.devnet.solana.com"
270        );
271    }
272}