Skip to main content

agent_first_pay/provider/
sol.rs

1use crate::provider::{HistorySyncStats, PayError, PayProvider};
2use crate::spend::tokens;
3use crate::store::wallet::{self, WalletMetadata};
4use crate::store::{PayStore, StorageBackend};
5use crate::types::*;
6use async_trait::async_trait;
7use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
8use base64::Engine;
9use bip39::Mnemonic;
10use serde::de::DeserializeOwned;
11use serde::Deserialize;
12use solana_sdk::hash::Hash;
13use solana_sdk::instruction::{AccountMeta, Instruction};
14use solana_sdk::pubkey::Pubkey;
15use solana_sdk::signature::{keypair_from_seed_phrase_and_passphrase, Keypair, Signer};
16use solana_sdk::transaction::Transaction;
17use solana_system_interface::instruction as system_instruction;
18use std::collections::HashMap;
19use std::str::FromStr;
20use std::sync::Arc;
21
22fn sol_wallet_summary(meta: WalletMetadata, address: String) -> WalletSummary {
23    WalletSummary {
24        id: meta.id,
25        network: Network::Sol,
26        label: meta.label,
27        address,
28        backend: None,
29        mint_url: None,
30        rpc_endpoints: meta.sol_rpc_endpoints,
31        chain_id: None,
32        created_at_epoch_s: meta.created_at_epoch_s,
33    }
34}
35
36pub struct SolProvider {
37    _data_dir: String,
38    http_client: reqwest::Client,
39    store: Arc<StorageBackend>,
40}
41
42const INVALID_SOL_WALLET_ADDRESS: &str = "invalid:sol-wallet-secret";
43const MAX_CHAIN_HISTORY_SCAN: usize = 200;
44const SOL_MEMO_PROGRAM_ID: &str = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
45const SPL_TOKEN_PROGRAM_ID: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
46const SPL_ASSOCIATED_TOKEN_PROGRAM_ID: &str = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL";
47
48#[derive(Debug, Clone)]
49struct SolTransferTarget {
50    recipient_address: String,
51    amount_lamports: u64,
52    /// If set, this is an SPL token transfer instead of the native token.
53    token_mint: Option<Pubkey>,
54}
55
56#[derive(Debug, Clone, Copy)]
57struct SolChainStatus {
58    status: TxStatus,
59    confirmations: Option<u32>,
60}
61
62impl SolProvider {
63    pub fn new(data_dir: &str, store: Arc<StorageBackend>) -> Self {
64        Self {
65            _data_dir: data_dir.to_string(),
66            http_client: reqwest::Client::new(),
67            store,
68        }
69    }
70
71    fn normalize_rpc_endpoint(raw: &str) -> Result<String, PayError> {
72        let trimmed = raw.trim();
73        if trimmed.is_empty() {
74            return Err(PayError::InvalidAmount(
75                "sol wallet requires --sol-rpc-endpoint".to_string(),
76            ));
77        }
78        let endpoint = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
79            trimmed.to_string()
80        } else {
81            format!("http://{trimmed}")
82        };
83        reqwest::Url::parse(&endpoint)
84            .map_err(|e| PayError::InvalidAmount(format!("invalid --sol-rpc-endpoint: {e}")))?;
85        Ok(endpoint)
86    }
87
88    #[cfg(test)]
89    fn decode_rpc_endpoint_list(raw: &str) -> Result<Vec<String>, PayError> {
90        let trimmed = raw.trim();
91        if trimmed.is_empty() {
92            return Err(PayError::InvalidAmount(
93                "sol wallet requires --sol-rpc-endpoint".to_string(),
94            ));
95        }
96        if !trimmed.starts_with('[') {
97            return Ok(vec![trimmed.to_string()]);
98        }
99        let values = serde_json::from_str::<Vec<String>>(trimmed).map_err(|e| {
100            PayError::InvalidAmount(format!(
101                "invalid --sol-rpc-endpoint list: expected JSON string array: {e}"
102            ))
103        })?;
104        if values.is_empty() {
105            return Err(PayError::InvalidAmount(
106                "--sol-rpc-endpoint requires at least one value".to_string(),
107            ));
108        }
109        Ok(values)
110    }
111
112    #[cfg(test)]
113    fn normalize_rpc_endpoints(raw: &str) -> Result<Vec<String>, PayError> {
114        let mut endpoints = Vec::new();
115        for candidate in Self::decode_rpc_endpoint_list(raw)? {
116            let normalized = Self::normalize_rpc_endpoint(&candidate)?;
117            if !endpoints.contains(&normalized) {
118                endpoints.push(normalized);
119            }
120        }
121        if endpoints.is_empty() {
122            return Err(PayError::InvalidAmount(
123                "--sol-rpc-endpoint requires at least one value".to_string(),
124            ));
125        }
126        Ok(endpoints)
127    }
128
129    fn keypair_from_seed_secret(seed_secret: &str) -> Result<Keypair, PayError> {
130        seed_secret.parse::<Mnemonic>().map_err(|_| {
131            PayError::InternalError(
132                "invalid sol wallet secret: expected BIP39 mnemonic words".to_string(),
133            )
134        })?;
135        keypair_from_seed_phrase_and_passphrase(seed_secret, "")
136            .map_err(|e| PayError::InternalError(format!("build keypair from sol mnemonic: {e}")))
137    }
138
139    fn wallet_keypair(meta: &WalletMetadata) -> Result<Keypair, PayError> {
140        let seed_secret = meta.seed_secret.as_deref().ok_or_else(|| {
141            PayError::InternalError(format!("wallet {} missing sol secret", meta.id))
142        })?;
143        Self::keypair_from_seed_secret(seed_secret)
144    }
145
146    fn wallet_address(meta: &WalletMetadata) -> Result<String, PayError> {
147        Ok(Self::wallet_keypair(meta)?.pubkey().to_string())
148    }
149
150    fn parse_transfer_target(
151        to: &str,
152        rpc_endpoints: &[String],
153    ) -> Result<SolTransferTarget, PayError> {
154        let trimmed = to.trim();
155        if trimmed.is_empty() {
156            return Err(PayError::InvalidAmount(
157                "sol send target is empty".to_string(),
158            ));
159        }
160        let no_scheme = trimmed.strip_prefix("solana:").unwrap_or(trimmed);
161        let (recipient, query) = match no_scheme.split_once('?') {
162            Some(parts) => parts,
163            None => (no_scheme, ""),
164        };
165        let recipient_address = recipient.trim();
166        if recipient_address.is_empty() {
167            return Err(PayError::InvalidAmount(
168                "sol send target missing recipient address".to_string(),
169            ));
170        }
171        let _ = Pubkey::from_str(recipient_address)
172            .map_err(|e| PayError::InvalidAmount(format!("invalid sol recipient address: {e}")))?;
173
174        let mut amount_lamports: Option<u64> = None;
175        let mut token_mint: Option<Pubkey> = None;
176        for pair in query.split('&') {
177            if pair.is_empty() {
178                continue;
179            }
180            let (key, value) = match pair.split_once('=') {
181                Some(kv) => kv,
182                None => (pair, ""),
183            };
184            match key {
185                "amount" | "amount-lamports" => {
186                    let parsed = value.parse::<u64>().map_err(|_| {
187                        PayError::InvalidAmount(format!("invalid amount value '{value}'"))
188                    })?;
189                    amount_lamports = Some(parsed);
190                }
191                "token" => {
192                    if value == "native" {
193                        // Explicit native token — no SPL mint
194                    } else {
195                        // Try as known symbol first, then as raw mint address
196                        let cluster = rpc_endpoints
197                            .first()
198                            .map(|e| tokens::sol_cluster_from_endpoint(e))
199                            .unwrap_or("mainnet-beta");
200                        if let Some(known) = tokens::resolve_sol_token(cluster, value) {
201                            token_mint = Some(Pubkey::from_str(known.address).map_err(|e| {
202                                PayError::InternalError(format!(
203                                    "invalid known token mint address: {e}"
204                                ))
205                            })?);
206                        } else {
207                            token_mint = Some(Pubkey::from_str(value).map_err(|e| {
208                                PayError::InvalidAmount(format!(
209                                    "unknown token '{value}'; provide a known symbol (native, usdc, usdt) or mint address: {e}"
210                                ))
211                            })?);
212                        }
213                    }
214                }
215                _ => {}
216            }
217        }
218        let Some(amount_lamports) = amount_lamports else {
219            return Err(PayError::InvalidAmount(
220                "sol send target missing amount; use solana:<address>?amount=<u64>&token=native"
221                    .to_string(),
222            ));
223        };
224        if amount_lamports == 0 {
225            return Err(PayError::InvalidAmount("amount must be >= 1".to_string()));
226        }
227
228        Ok(SolTransferTarget {
229            recipient_address: recipient_address.to_string(),
230            amount_lamports,
231            token_mint,
232        })
233    }
234
235    fn load_sol_wallet(&self, wallet_id: &str) -> Result<WalletMetadata, PayError> {
236        let meta = self.store.load_wallet_metadata(wallet_id)?;
237        if meta.network != Network::Sol {
238            return Err(PayError::WalletNotFound(format!(
239                "{wallet_id} is not a sol wallet"
240            )));
241        }
242        Ok(meta)
243    }
244
245    fn resolve_wallet_id(&self, wallet_id: &str) -> Result<String, PayError> {
246        if !wallet_id.trim().is_empty() {
247            return Ok(wallet_id.to_string());
248        }
249        let wallets = self.store.list_wallet_metadata(Some(Network::Sol))?;
250        match wallets.len() {
251            0 => Err(PayError::WalletNotFound("no sol wallet found".to_string())),
252            1 => Ok(wallets[0].id.clone()),
253            _ => Err(PayError::InvalidAmount(
254                "multiple sol wallets found; pass --wallet".to_string(),
255            )),
256        }
257    }
258
259    fn rpc_endpoints_for_wallet(meta: &WalletMetadata) -> Result<Vec<String>, PayError> {
260        let Some(configured) = meta.sol_rpc_endpoints.as_ref() else {
261            return Err(PayError::InternalError(format!(
262                "wallet {} missing sol rpc endpoints; recreate wallet",
263                meta.id
264            )));
265        };
266        let mut endpoints = Vec::new();
267        for candidate in configured {
268            let normalized = Self::normalize_rpc_endpoint(candidate)?;
269            if !endpoints.contains(&normalized) {
270                endpoints.push(normalized);
271            }
272        }
273        if endpoints.is_empty() {
274            return Err(PayError::InternalError(format!(
275                "wallet {} has empty sol rpc endpoints; recreate wallet",
276                meta.id
277            )));
278        }
279        Ok(endpoints)
280    }
281
282    async fn rpc_call<T>(
283        &self,
284        endpoint: &str,
285        method: &str,
286        params: serde_json::Value,
287    ) -> Result<T, PayError>
288    where
289        T: DeserializeOwned,
290    {
291        let payload = serde_json::json!({
292            "jsonrpc": "2.0",
293            "id": 1,
294            "method": method,
295            "params": params,
296        });
297
298        let response = self
299            .http_client
300            .post(endpoint)
301            .json(&payload)
302            .send()
303            .await
304            .map_err(|e| PayError::NetworkError(format!("sol rpc {method} request: {e}")))?;
305
306        let status = response.status();
307        let body = response
308            .text()
309            .await
310            .map_err(|e| PayError::NetworkError(format!("sol rpc {method} read body: {e}")))?;
311
312        if !status.is_success() {
313            return Err(PayError::NetworkError(format!(
314                "sol rpc {method} {}: {}",
315                status.as_u16(),
316                body
317            )));
318        }
319
320        let envelope: SolRpcEnvelope<T> = serde_json::from_str(&body)
321            .map_err(|e| PayError::NetworkError(format!("sol rpc {method} decode: {e}")))?;
322
323        if let Some(error) = envelope.error {
324            return Err(PayError::NetworkError(format!(
325                "sol rpc {method} {}: {}",
326                error.code, error.message
327            )));
328        }
329
330        envelope
331            .result
332            .ok_or_else(|| PayError::NetworkError(format!("sol rpc {method} missing result field")))
333    }
334
335    async fn rpc_call_with_failover<T>(
336        &self,
337        endpoints: &[String],
338        method: &str,
339        params: serde_json::Value,
340    ) -> Result<T, PayError>
341    where
342        T: DeserializeOwned,
343    {
344        let mut last_error: Option<String> = None;
345        for endpoint in endpoints {
346            match self.rpc_call(endpoint, method, params.clone()).await {
347                Ok(result) => return Ok(result),
348                Err(err) => {
349                    last_error = Some(format!("endpoint={endpoint} err={err}"));
350                }
351            }
352        }
353        Err(PayError::NetworkError(format!(
354            "all sol rpc endpoints failed for {method}; {}",
355            last_error.unwrap_or_else(|| "no endpoints configured".to_string())
356        )))
357    }
358
359    async fn fetch_chain_status(
360        &self,
361        endpoint: &str,
362        transaction_id: &str,
363    ) -> Result<Option<SolChainStatus>, PayError> {
364        let result: SolGetSignatureStatusesResult = self
365            .rpc_call(
366                endpoint,
367                "getSignatureStatuses",
368                serde_json::json!([[transaction_id], {"searchTransactionHistory": true}]),
369            )
370            .await?;
371        let Some(entry) = result.value.into_iter().next().flatten() else {
372            return Ok(None);
373        };
374
375        if entry.err.is_some() {
376            return Ok(Some(SolChainStatus {
377                status: TxStatus::Failed,
378                confirmations: entry.confirmations.map(|v| v as u32),
379            }));
380        }
381
382        let status = match entry.confirmation_status.as_deref() {
383            Some("finalized") | Some("confirmed") => TxStatus::Confirmed,
384            Some("processed") => TxStatus::Pending,
385            Some(_) => TxStatus::Pending,
386            None => {
387                if entry.confirmations.is_none() {
388                    TxStatus::Confirmed
389                } else {
390                    TxStatus::Pending
391                }
392            }
393        };
394        Ok(Some(SolChainStatus {
395            status,
396            confirmations: entry.confirmations.map(|v| v as u32),
397        }))
398    }
399
400    fn tx_status_from_chain(confirmation_status: Option<&str>, has_error: bool) -> TxStatus {
401        if has_error {
402            return TxStatus::Failed;
403        }
404        match confirmation_status {
405            Some("finalized") | Some("confirmed") => TxStatus::Confirmed,
406            Some("processed") | Some(_) => TxStatus::Pending,
407            None => TxStatus::Pending,
408        }
409    }
410
411    /// Derive the Associated Token Account address for (wallet, mint).
412    fn derive_ata(wallet: &Pubkey, mint: &Pubkey) -> Result<Pubkey, PayError> {
413        let token_program = Pubkey::from_str(SPL_TOKEN_PROGRAM_ID)
414            .map_err(|e| PayError::InternalError(format!("invalid spl token program id: {e}")))?;
415        let ata_program = Pubkey::from_str(SPL_ASSOCIATED_TOKEN_PROGRAM_ID)
416            .map_err(|e| PayError::InternalError(format!("invalid ata program id: {e}")))?;
417        let (ata, _bump) = Pubkey::find_program_address(
418            &[wallet.as_ref(), token_program.as_ref(), mint.as_ref()],
419            &ata_program,
420        );
421        Ok(ata)
422    }
423
424    /// Build a create-associated-token-account instruction.
425    fn build_create_ata_instruction(
426        funder: &Pubkey,
427        owner: &Pubkey,
428        mint: &Pubkey,
429    ) -> Result<Instruction, PayError> {
430        let token_program = Pubkey::from_str(SPL_TOKEN_PROGRAM_ID)
431            .map_err(|e| PayError::InternalError(format!("invalid spl token program id: {e}")))?;
432        let ata_program = Pubkey::from_str(SPL_ASSOCIATED_TOKEN_PROGRAM_ID)
433            .map_err(|e| PayError::InternalError(format!("invalid ata program id: {e}")))?;
434        let ata = Self::derive_ata(owner, mint)?;
435        // System program: 11111111111111111111111111111111
436        let system_program = Pubkey::default();
437        Ok(Instruction {
438            program_id: ata_program,
439            accounts: vec![
440                AccountMeta::new(*funder, true),
441                AccountMeta::new(ata, false),
442                AccountMeta::new_readonly(*owner, false),
443                AccountMeta::new_readonly(*mint, false),
444                AccountMeta::new_readonly(system_program, false),
445                AccountMeta::new_readonly(token_program, false),
446            ],
447            data: vec![], // CreateAssociatedTokenAccount has no data
448        })
449    }
450
451    /// Build an SPL token transfer_checked instruction.
452    fn build_spl_transfer_instruction(
453        source_ata: &Pubkey,
454        mint: &Pubkey,
455        dest_ata: &Pubkey,
456        authority: &Pubkey,
457        amount: u64,
458        decimals: u8,
459    ) -> Result<Instruction, PayError> {
460        let token_program = Pubkey::from_str(SPL_TOKEN_PROGRAM_ID)
461            .map_err(|e| PayError::InternalError(format!("invalid spl token program id: {e}")))?;
462        // transfer_checked instruction data: [12u8, amount(8 bytes LE), decimals(1 byte)]
463        let mut data = Vec::with_capacity(10);
464        data.push(12u8); // transfer_checked discriminator
465        data.extend_from_slice(&amount.to_le_bytes());
466        data.push(decimals);
467        Ok(Instruction {
468            program_id: token_program,
469            accounts: vec![
470                AccountMeta::new(*source_ata, false),
471                AccountMeta::new_readonly(*mint, false),
472                AccountMeta::new(*dest_ata, false),
473                AccountMeta::new_readonly(*authority, true),
474            ],
475            data,
476        })
477    }
478
479    /// Check if an account exists on-chain (non-null value from getAccountInfo).
480    async fn account_exists(&self, endpoints: &[String], address: &str) -> Result<bool, PayError> {
481        let result: serde_json::Value = self
482            .rpc_call_with_failover(
483                endpoints,
484                "getAccountInfo",
485                serde_json::json!([address, {"encoding": "base64"}]),
486            )
487            .await?;
488        Ok(result.get("value").is_some_and(|v| !v.is_null()))
489    }
490
491    /// Query SPL token accounts by owner and add known token balances to BalanceInfo.
492    async fn enrich_with_token_balances(
493        &self,
494        endpoints: &[String],
495        address: &str,
496        custom_tokens: &[wallet::CustomToken],
497        balance: &mut BalanceInfo,
498    ) {
499        // Detect cluster from first endpoint
500        let cluster = endpoints
501            .first()
502            .map(|e| tokens::sol_cluster_from_endpoint(e))
503            .unwrap_or("mainnet-beta");
504
505        for known in tokens::sol_known_tokens(cluster) {
506            self.query_spl_token_balance(
507                endpoints,
508                address,
509                known.symbol,
510                known.address,
511                known.decimals,
512                balance,
513            )
514            .await;
515        }
516        for ct in custom_tokens {
517            self.query_spl_token_balance(
518                endpoints,
519                address,
520                &ct.symbol,
521                &ct.address,
522                ct.decimals,
523                balance,
524            )
525            .await;
526        }
527    }
528
529    async fn query_spl_token_balance(
530        &self,
531        endpoints: &[String],
532        address: &str,
533        symbol: &str,
534        mint_address: &str,
535        decimals: u8,
536        balance: &mut BalanceInfo,
537    ) {
538        let mint_pubkey = match Pubkey::from_str(mint_address) {
539            Ok(p) => p,
540            Err(_) => return,
541        };
542        let owner_pubkey = match Pubkey::from_str(address) {
543            Ok(p) => p,
544            Err(_) => return,
545        };
546        let ata = match Self::derive_ata(&owner_pubkey, &mint_pubkey) {
547            Ok(a) => a,
548            Err(_) => return,
549        };
550        let result: Result<serde_json::Value, _> = self
551            .rpc_call_with_failover(
552                endpoints,
553                "getTokenAccountBalance",
554                serde_json::json!([ata.to_string()]),
555            )
556            .await;
557        if let Ok(val) = result {
558            if let Some(amount_str) = val
559                .get("value")
560                .and_then(|v| v.get("amount"))
561                .and_then(|v| v.as_str())
562            {
563                if let Ok(amount) = amount_str.parse::<u64>() {
564                    if amount > 0 {
565                        balance
566                            .additional
567                            .insert(format!("{symbol}_base_units"), amount);
568                        balance
569                            .additional
570                            .insert(format!("{symbol}_decimals"), decimals as u64);
571                    }
572                }
573            }
574        }
575    }
576
577    fn build_memo_instruction(memo_text: &str, signer: &Pubkey) -> Result<Instruction, PayError> {
578        let memo_program = Pubkey::from_str(SOL_MEMO_PROGRAM_ID)
579            .map_err(|e| PayError::InternalError(format!("invalid memo program id: {e}")))?;
580        Ok(Instruction {
581            program_id: memo_program,
582            accounts: vec![AccountMeta::new_readonly(*signer, true)],
583            data: memo_text.as_bytes().to_vec(),
584        })
585    }
586
587    fn extract_memo_from_transaction(tx: &SolGetTransactionResult) -> Option<String> {
588        for ix in &tx.transaction.message.instructions {
589            let Some(program_id) = tx.transaction.message.account_keys.get(ix.program_id_index)
590            else {
591                continue;
592            };
593            if program_id != SOL_MEMO_PROGRAM_ID || ix.data.trim().is_empty() {
594                continue;
595            }
596            let memo_bytes = bs58::decode(&ix.data).into_vec().ok()?;
597            let memo = String::from_utf8(memo_bytes).ok()?;
598            if memo.trim().is_empty() {
599                continue;
600            }
601            return Some(memo);
602        }
603        None
604    }
605
606    async fn fetch_recent_chain_signatures(
607        &self,
608        endpoints: &[String],
609        address: &str,
610        limit: usize,
611    ) -> Result<Vec<SolAddressSignatureEntry>, PayError> {
612        self.rpc_call_with_failover(
613            endpoints,
614            "getSignaturesForAddress",
615            serde_json::json!([address, {"limit": limit}]),
616        )
617        .await
618    }
619
620    async fn fetch_chain_transaction_record(
621        &self,
622        endpoints: &[String],
623        wallet_id: &str,
624        wallet_address: &str,
625        signature: &SolAddressSignatureEntry,
626    ) -> Result<Option<HistoryRecord>, PayError> {
627        let tx_value: serde_json::Value = self
628            .rpc_call_with_failover(
629                endpoints,
630                "getTransaction",
631                serde_json::json!([
632                    signature.signature,
633                    {
634                        "encoding": "json",
635                        "maxSupportedTransactionVersion": 0
636                    }
637                ]),
638            )
639            .await?;
640
641        if tx_value.is_null() {
642            return Ok(None);
643        }
644
645        let tx: SolGetTransactionResult = serde_json::from_value(tx_value).map_err(|e| {
646            PayError::NetworkError(format!(
647                "sol rpc getTransaction decode {}: {e}",
648                signature.signature
649            ))
650        })?;
651
652        let wallet_index = tx
653            .transaction
654            .message
655            .account_keys
656            .iter()
657            .position(|key| key == wallet_address);
658        let Some(wallet_index) = wallet_index else {
659            return Ok(None);
660        };
661
662        let pre = tx.meta.pre_balances.get(wallet_index).copied().unwrap_or(0);
663        let post = tx
664            .meta
665            .post_balances
666            .get(wallet_index)
667            .copied()
668            .unwrap_or(0);
669        if pre == post {
670            return Ok(None);
671        }
672
673        let delta = post as i128 - pre as i128;
674        let amount_value = if delta >= 0 {
675            delta as u64
676        } else {
677            (-delta) as u64
678        };
679        let direction = if delta >= 0 {
680            Direction::Receive
681        } else {
682            Direction::Send
683        };
684
685        let status = Self::tx_status_from_chain(
686            signature.confirmation_status.as_deref(),
687            signature.err.is_some() || tx.meta.err.is_some(),
688        );
689        let created_at_epoch_s = signature
690            .block_time
691            .or(tx.block_time)
692            .unwrap_or_else(wallet::now_epoch_seconds);
693        let confirmed_at_epoch_s = (status == TxStatus::Confirmed).then_some(created_at_epoch_s);
694
695        let fee_amount = if tx.meta.fee > 0 {
696            Some(Amount {
697                value: tx.meta.fee,
698                token: "lamports".to_string(),
699            })
700        } else {
701            None
702        };
703        Ok(Some(HistoryRecord {
704            transaction_id: signature.signature.clone(),
705            wallet: wallet_id.to_string(),
706            network: Network::Sol,
707            direction,
708            amount: Amount {
709                value: amount_value,
710                token: "lamports".to_string(),
711            },
712            status,
713            onchain_memo: Self::extract_memo_from_transaction(&tx),
714            local_memo: None,
715            remote_addr: None,
716            preimage: None,
717            created_at_epoch_s,
718            confirmed_at_epoch_s,
719            fee: fee_amount,
720        }))
721    }
722
723    async fn fetch_chain_history_records(
724        &self,
725        wallet_id: &str,
726        fetch_limit: usize,
727    ) -> Result<Vec<HistoryRecord>, PayError> {
728        let meta = self.load_sol_wallet(wallet_id)?;
729        let endpoints = Self::rpc_endpoints_for_wallet(&meta)?;
730        let address = Self::wallet_address(&meta)?;
731        let signatures = self
732            .fetch_recent_chain_signatures(&endpoints, &address, fetch_limit)
733            .await?;
734
735        let mut records = Vec::new();
736        for signature in &signatures {
737            match self
738                .fetch_chain_transaction_record(&endpoints, wallet_id, &address, signature)
739                .await
740            {
741                Ok(Some(record)) => records.push(record),
742                Ok(None) => {}
743                Err(_) => {}
744            }
745        }
746        Ok(records)
747    }
748
749    async fn fetch_chain_record_for_wallet(
750        &self,
751        wallet_id: &str,
752        transaction_id: &str,
753    ) -> Result<Option<(HistoryRecord, Option<u32>)>, PayError> {
754        let meta = self.load_sol_wallet(wallet_id)?;
755        let endpoints = Self::rpc_endpoints_for_wallet(&meta)?;
756        let address = Self::wallet_address(&meta)?;
757
758        let Some(chain_status) = self
759            .fetch_chain_status_for_wallet(wallet_id, transaction_id)
760            .await?
761        else {
762            return Ok(None);
763        };
764
765        let tx_value: serde_json::Value = self
766            .rpc_call_with_failover(
767                &endpoints,
768                "getTransaction",
769                serde_json::json!([
770                    transaction_id,
771                    {
772                        "encoding": "json",
773                        "maxSupportedTransactionVersion": 0
774                    }
775                ]),
776            )
777            .await?;
778        if tx_value.is_null() {
779            return Ok(None);
780        }
781
782        let tx: SolGetTransactionResult = serde_json::from_value(tx_value).map_err(|e| {
783            PayError::NetworkError(format!(
784                "sol rpc getTransaction decode {transaction_id}: {e}"
785            ))
786        })?;
787        let wallet_index = tx
788            .transaction
789            .message
790            .account_keys
791            .iter()
792            .position(|key| key == &address);
793        let Some(wallet_index) = wallet_index else {
794            return Ok(None);
795        };
796
797        let pre = tx.meta.pre_balances.get(wallet_index).copied().unwrap_or(0);
798        let post = tx
799            .meta
800            .post_balances
801            .get(wallet_index)
802            .copied()
803            .unwrap_or(0);
804        let delta = post as i128 - pre as i128;
805        let direction = if delta >= 0 {
806            Direction::Receive
807        } else {
808            Direction::Send
809        };
810        let amount_value = if delta >= 0 {
811            delta as u64
812        } else {
813            (-delta) as u64
814        };
815        let created_at_epoch_s = tx.block_time.unwrap_or_else(wallet::now_epoch_seconds);
816        let confirmed_at_epoch_s =
817            (chain_status.status == TxStatus::Confirmed).then_some(created_at_epoch_s);
818
819        let fee_amount = if tx.meta.fee > 0 {
820            Some(Amount {
821                value: tx.meta.fee,
822                token: "lamports".to_string(),
823            })
824        } else {
825            None
826        };
827        Ok(Some((
828            HistoryRecord {
829                transaction_id: transaction_id.to_string(),
830                wallet: wallet_id.to_string(),
831                network: Network::Sol,
832                direction,
833                amount: Amount {
834                    value: amount_value,
835                    token: "lamports".to_string(),
836                },
837                status: chain_status.status,
838                onchain_memo: Self::extract_memo_from_transaction(&tx),
839                local_memo: None,
840                remote_addr: None,
841                preimage: None,
842                created_at_epoch_s,
843                confirmed_at_epoch_s,
844                fee: fee_amount,
845            },
846            chain_status.confirmations,
847        )))
848    }
849
850    async fn fetch_chain_record_across_wallets(
851        &self,
852        transaction_id: &str,
853    ) -> Result<Option<(HistoryRecord, Option<u32>)>, PayError> {
854        let wallets = self.store.list_wallet_metadata(Some(Network::Sol))?;
855        for wallet in wallets {
856            if let Some(record) = self
857                .fetch_chain_record_for_wallet(&wallet.id, transaction_id)
858                .await?
859            {
860                return Ok(Some(record));
861            }
862        }
863        Ok(None)
864    }
865
866    async fn fetch_chain_status_for_wallet(
867        &self,
868        wallet_id: &str,
869        transaction_id: &str,
870    ) -> Result<Option<SolChainStatus>, PayError> {
871        let meta = self.load_sol_wallet(wallet_id)?;
872        let endpoints = Self::rpc_endpoints_for_wallet(&meta)?;
873        let mut last_error: Option<PayError> = None;
874        for endpoint in &endpoints {
875            match self.fetch_chain_status(endpoint, transaction_id).await {
876                Ok(status) => return Ok(status),
877                Err(err) => {
878                    last_error = Some(err);
879                }
880            }
881        }
882        match last_error {
883            Some(err) => Err(err),
884            None => Ok(None),
885        }
886    }
887
888    async fn fetch_chain_status_across_wallets(
889        &self,
890        transaction_id: &str,
891    ) -> Result<Option<SolChainStatus>, PayError> {
892        let wallets = self.store.list_wallet_metadata(Some(Network::Sol))?;
893        for meta in wallets {
894            let Ok(endpoints) = Self::rpc_endpoints_for_wallet(&meta) else {
895                continue;
896            };
897            for endpoint in &endpoints {
898                match self.fetch_chain_status(endpoint, transaction_id).await {
899                    Ok(Some(status)) => return Ok(Some(status)),
900                    Ok(None) => {}
901                    Err(_) => {}
902                }
903            }
904        }
905        Ok(None)
906    }
907}
908
909#[derive(Debug, Deserialize)]
910struct SolRpcEnvelope<T> {
911    result: Option<T>,
912    error: Option<SolRpcError>,
913}
914
915#[derive(Debug, Deserialize)]
916struct SolRpcError {
917    code: i64,
918    message: String,
919}
920
921#[derive(Debug, Deserialize)]
922struct SolGetBalanceResult {
923    value: u64,
924}
925
926#[derive(Debug, Deserialize)]
927struct SolGetLatestBlockhashResult {
928    value: SolGetLatestBlockhashValue,
929}
930
931#[derive(Debug, Deserialize)]
932struct SolGetLatestBlockhashValue {
933    blockhash: String,
934}
935
936#[derive(Debug, Deserialize)]
937struct SolGetSignatureStatusesResult {
938    value: Vec<Option<SolSignatureStatusValue>>,
939}
940
941#[derive(Debug, Deserialize)]
942struct SolSignatureStatusValue {
943    confirmations: Option<u64>,
944    err: Option<serde_json::Value>,
945    #[serde(rename = "confirmationStatus")]
946    confirmation_status: Option<String>,
947}
948
949#[derive(Debug, Deserialize)]
950#[serde(rename_all = "camelCase")]
951struct SolAddressSignatureEntry {
952    signature: String,
953    err: Option<serde_json::Value>,
954    block_time: Option<u64>,
955    confirmation_status: Option<String>,
956}
957
958#[derive(Debug, Deserialize)]
959#[serde(rename_all = "camelCase")]
960struct SolGetTransactionResult {
961    meta: SolTransactionMeta,
962    transaction: SolTransactionEnvelope,
963    block_time: Option<u64>,
964}
965
966#[derive(Debug, Deserialize)]
967#[serde(rename_all = "camelCase")]
968struct SolTransactionMeta {
969    pre_balances: Vec<u64>,
970    post_balances: Vec<u64>,
971    err: Option<serde_json::Value>,
972    #[serde(default)]
973    fee: u64,
974}
975
976#[derive(Debug, Deserialize)]
977struct SolTransactionEnvelope {
978    message: SolTransactionMessage,
979}
980
981#[derive(Debug, Deserialize)]
982#[serde(rename_all = "camelCase")]
983struct SolTransactionMessage {
984    account_keys: Vec<String>,
985    #[serde(default)]
986    instructions: Vec<SolCompiledInstruction>,
987}
988
989#[derive(Debug, Deserialize)]
990#[serde(rename_all = "camelCase")]
991struct SolCompiledInstruction {
992    program_id_index: usize,
993    #[serde(default)]
994    data: String,
995}
996
997#[async_trait]
998impl PayProvider for SolProvider {
999    fn network(&self) -> Network {
1000        Network::Sol
1001    }
1002
1003    fn writes_locally(&self) -> bool {
1004        true
1005    }
1006
1007    async fn create_wallet(&self, request: &WalletCreateRequest) -> Result<WalletInfo, PayError> {
1008        let endpoints = if request.rpc_endpoints.is_empty() {
1009            return Err(PayError::InvalidAmount(
1010                "sol wallet requires --sol-rpc-endpoint (or rpc_endpoints in JSON)".to_string(),
1011            ));
1012        } else {
1013            let mut normalized = Vec::new();
1014            for ep in &request.rpc_endpoints {
1015                let n = Self::normalize_rpc_endpoint(ep)?;
1016                if !normalized.contains(&n) {
1017                    normalized.push(n);
1018                }
1019            }
1020            normalized
1021        };
1022        let mnemonic_str = if let Some(raw) = request.mnemonic_secret.as_deref() {
1023            let mnemonic: Mnemonic = raw.parse().map_err(|_| {
1024                PayError::InvalidAmount(
1025                    "invalid mnemonic-secret for sol wallet: expected BIP39 words".to_string(),
1026                )
1027            })?;
1028            mnemonic.words().collect::<Vec<_>>().join(" ")
1029        } else {
1030            let mut entropy = [0u8; 16];
1031            getrandom::fill(&mut entropy)
1032                .map_err(|e| PayError::InternalError(format!("rng failed: {e}")))?;
1033            let mnemonic = Mnemonic::from_entropy(&entropy)
1034                .map_err(|e| PayError::InternalError(format!("mnemonic gen: {e}")))?;
1035            mnemonic.words().collect::<Vec<_>>().join(" ")
1036        };
1037        let keypair = keypair_from_seed_phrase_and_passphrase(&mnemonic_str, "").map_err(|e| {
1038            PayError::InternalError(format!("build keypair from sol mnemonic: {e}"))
1039        })?;
1040        let address = keypair.pubkey().to_string();
1041
1042        let wallet_id = wallet::generate_wallet_identifier()?;
1043        let normalized_label = {
1044            let trimmed = request.label.trim();
1045            if trimmed.is_empty() || trimmed == "default" {
1046                None
1047            } else {
1048                Some(trimmed.to_string())
1049            }
1050        };
1051
1052        let meta = WalletMetadata {
1053            id: wallet_id.clone(),
1054            network: Network::Sol,
1055            label: normalized_label.clone(),
1056            mint_url: None,
1057            sol_rpc_endpoints: Some(endpoints),
1058            evm_rpc_endpoints: None,
1059            evm_chain_id: None,
1060            seed_secret: Some(mnemonic_str.clone()),
1061            backend: None,
1062            btc_esplora_url: None,
1063            btc_network: None,
1064            btc_address_type: None,
1065            btc_core_url: None,
1066            btc_core_auth_secret: None,
1067            btc_electrum_url: None,
1068            custom_tokens: None,
1069            created_at_epoch_s: wallet::now_epoch_seconds(),
1070            error: None,
1071        };
1072        self.store.save_wallet_metadata(&meta)?;
1073
1074        Ok(WalletInfo {
1075            id: wallet_id,
1076            network: Network::Sol,
1077            address,
1078            label: normalized_label,
1079            mnemonic: None,
1080        })
1081    }
1082
1083    async fn close_wallet(&self, wallet_id: &str) -> Result<(), PayError> {
1084        let balance = self.balance(wallet_id).await?;
1085        let non_zero_components = balance.non_zero_components();
1086        if !non_zero_components.is_empty() {
1087            let component_list = non_zero_components
1088                .iter()
1089                .map(|(name, value)| format!("{name}={value}"))
1090                .collect::<Vec<_>>()
1091                .join(", ");
1092            return Err(PayError::InvalidAmount(format!(
1093                "wallet {wallet_id} has non-zero balance components ({component_list}); transfer funds first"
1094            )));
1095        }
1096        self.store.delete_wallet_metadata(wallet_id)
1097    }
1098
1099    async fn list_wallets(&self) -> Result<Vec<WalletSummary>, PayError> {
1100        let wallets = self.store.list_wallet_metadata(Some(Network::Sol))?;
1101        Ok(wallets
1102            .into_iter()
1103            .map(|meta| {
1104                let address = Self::wallet_address(&meta)
1105                    .unwrap_or_else(|_| INVALID_SOL_WALLET_ADDRESS.to_string());
1106                sol_wallet_summary(meta, address)
1107            })
1108            .collect())
1109    }
1110
1111    async fn balance(&self, wallet_id: &str) -> Result<BalanceInfo, PayError> {
1112        let resolved = self.resolve_wallet_id(wallet_id)?;
1113        let meta = self.load_sol_wallet(&resolved)?;
1114        let endpoints = Self::rpc_endpoints_for_wallet(&meta)?;
1115        let address = Self::wallet_address(&meta)?;
1116        let result: SolGetBalanceResult = self
1117            .rpc_call_with_failover(
1118                &endpoints,
1119                "getBalance",
1120                serde_json::json!([address, {"commitment": "confirmed"}]),
1121            )
1122            .await?;
1123        let custom_tokens = meta.custom_tokens.as_deref().unwrap_or_default();
1124        let lamports = result.value;
1125        let mut info = BalanceInfo::new(lamports, 0, "lamports");
1126        self.enrich_with_token_balances(&endpoints, &address, custom_tokens, &mut info)
1127            .await;
1128        Ok(info)
1129    }
1130
1131    async fn balance_all(&self) -> Result<Vec<WalletBalanceItem>, PayError> {
1132        let wallets = self.store.list_wallet_metadata(Some(Network::Sol))?;
1133        let mut items = Vec::with_capacity(wallets.len());
1134        for meta in wallets {
1135            let custom_tokens = meta.custom_tokens.as_deref().unwrap_or_default().to_vec();
1136            let endpoints = Self::rpc_endpoints_for_wallet(&meta);
1137            let address = Self::wallet_address(&meta);
1138            let result = match (endpoints, address) {
1139                (Ok(endpoints), Ok(address)) => {
1140                    let rpc_result: Result<SolGetBalanceResult, PayError> = self
1141                        .rpc_call_with_failover(
1142                            &endpoints,
1143                            "getBalance",
1144                            serde_json::json!([address, {"commitment": "confirmed"}]),
1145                        )
1146                        .await;
1147                    match rpc_result {
1148                        Ok(v) => {
1149                            let mut info = BalanceInfo::new(v.value, 0, "lamports");
1150                            self.enrich_with_token_balances(
1151                                &endpoints,
1152                                &address,
1153                                &custom_tokens,
1154                                &mut info,
1155                            )
1156                            .await;
1157                            Ok(info)
1158                        }
1159                        Err(e) => Err(e),
1160                    }
1161                }
1162                (Err(e), _) | (_, Err(e)) => Err(e),
1163            };
1164            let summary_address = Self::wallet_address(&meta)
1165                .unwrap_or_else(|_| INVALID_SOL_WALLET_ADDRESS.to_string());
1166            let summary = sol_wallet_summary(meta, summary_address);
1167            match result {
1168                Ok(info) => items.push(WalletBalanceItem {
1169                    wallet: summary,
1170                    balance: Some(info),
1171                    error: None,
1172                }),
1173                Err(error) => items.push(WalletBalanceItem {
1174                    wallet: summary,
1175                    balance: None,
1176                    error: Some(error.to_string()),
1177                }),
1178            }
1179        }
1180        Ok(items)
1181    }
1182
1183    async fn receive_info(
1184        &self,
1185        wallet_id: &str,
1186        _amount: Option<Amount>,
1187    ) -> Result<ReceiveInfo, PayError> {
1188        let resolved = self.resolve_wallet_id(wallet_id)?;
1189        let meta = self.load_sol_wallet(&resolved)?;
1190        let _ = Self::rpc_endpoints_for_wallet(&meta)?;
1191        Ok(ReceiveInfo {
1192            address: Some(Self::wallet_address(&meta)?),
1193            invoice: None,
1194            quote_id: None,
1195        })
1196    }
1197
1198    async fn receive_claim(&self, _wallet: &str, _quote_id: &str) -> Result<u64, PayError> {
1199        Err(PayError::NotImplemented(
1200            "sol receive has no claim step".to_string(),
1201        ))
1202    }
1203
1204    async fn cashu_send(
1205        &self,
1206        _wallet: &str,
1207        _amount: Amount,
1208        _onchain_memo: Option<&str>,
1209        _mints: Option<&[String]>,
1210    ) -> Result<CashuSendResult, PayError> {
1211        Err(PayError::NotImplemented(
1212            "sol does not use cashu send".to_string(),
1213        ))
1214    }
1215
1216    async fn cashu_receive(
1217        &self,
1218        _wallet: &str,
1219        _token: &str,
1220    ) -> Result<CashuReceiveResult, PayError> {
1221        Err(PayError::NotImplemented(
1222            "sol does not use cashu receive".to_string(),
1223        ))
1224    }
1225
1226    async fn send(
1227        &self,
1228        wallet: &str,
1229        to: &str,
1230        onchain_memo: Option<&str>,
1231        _mints: Option<&[String]>,
1232    ) -> Result<SendResult, PayError> {
1233        let resolved_wallet_id = self.resolve_wallet_id(wallet)?;
1234        let meta = self.load_sol_wallet(&resolved_wallet_id)?;
1235        let endpoints = Self::rpc_endpoints_for_wallet(&meta)?;
1236        let transfer_target = Self::parse_transfer_target(to, &endpoints)?;
1237        let recipient_pubkey = Pubkey::from_str(&transfer_target.recipient_address)
1238            .map_err(|e| PayError::InvalidAmount(format!("invalid sol recipient address: {e}")))?;
1239
1240        let keypair = Self::wallet_keypair(&meta)?;
1241        let memo_instruction = onchain_memo
1242            .map(str::trim)
1243            .filter(|text| !text.is_empty())
1244            .map(|text| Self::build_memo_instruction(text, &keypair.pubkey()))
1245            .transpose()?;
1246
1247        // Build SPL token transfer instructions if token_mint is set
1248        let spl_instructions = if let Some(token_mint) = transfer_target.token_mint {
1249            let cluster = endpoints
1250                .first()
1251                .map(|e| tokens::sol_cluster_from_endpoint(e))
1252                .unwrap_or("mainnet-beta");
1253            let decimals = tokens::sol_known_tokens(cluster)
1254                .iter()
1255                .find(|t| Pubkey::from_str(t.address).ok().as_ref() == Some(&token_mint))
1256                .map(|t| t.decimals)
1257                .unwrap_or(6); // default to 6 decimals for unknown tokens
1258
1259            let sender_ata = Self::derive_ata(&keypair.pubkey(), &token_mint)?;
1260            let recipient_ata = Self::derive_ata(&recipient_pubkey, &token_mint)?;
1261
1262            let mut ixs = Vec::new();
1263            // Check if recipient ATA exists; if not, create it
1264            let recipient_ata_exists = self
1265                .account_exists(&endpoints, &recipient_ata.to_string())
1266                .await
1267                .unwrap_or(false);
1268            if !recipient_ata_exists {
1269                ixs.push(Self::build_create_ata_instruction(
1270                    &keypair.pubkey(),
1271                    &recipient_pubkey,
1272                    &token_mint,
1273                )?);
1274            }
1275            ixs.push(Self::build_spl_transfer_instruction(
1276                &sender_ata,
1277                &token_mint,
1278                &recipient_ata,
1279                &keypair.pubkey(),
1280                transfer_target.amount_lamports,
1281                decimals,
1282            )?);
1283            Some(ixs)
1284        } else {
1285            None
1286        };
1287
1288        let mut last_error: Option<String> = None;
1289        let mut transaction_id: Option<String> = None;
1290        for endpoint in &endpoints {
1291            let latest_blockhash: SolGetLatestBlockhashResult = match self
1292                .rpc_call(
1293                    endpoint,
1294                    "getLatestBlockhash",
1295                    serde_json::json!([{"commitment":"confirmed"}]),
1296                )
1297                .await
1298            {
1299                Ok(result) => result,
1300                Err(err) => {
1301                    last_error = Some(format!("endpoint={endpoint} getLatestBlockhash: {err}"));
1302                    continue;
1303                }
1304            };
1305
1306            let recent_blockhash = match Hash::from_str(&latest_blockhash.value.blockhash) {
1307                Ok(hash) => hash,
1308                Err(err) => {
1309                    last_error = Some(format!(
1310                        "endpoint={endpoint} invalid latest blockhash: {err}"
1311                    ));
1312                    continue;
1313                }
1314            };
1315
1316            let mut instructions = Vec::new();
1317            if let Some(ix) = memo_instruction.as_ref() {
1318                instructions.push(ix.clone());
1319            }
1320            if let Some(ref spl_ixs) = spl_instructions {
1321                instructions.extend(spl_ixs.iter().cloned());
1322            } else {
1323                let transfer_ix = system_instruction::transfer(
1324                    &keypair.pubkey(),
1325                    &recipient_pubkey,
1326                    transfer_target.amount_lamports,
1327                );
1328                instructions.push(transfer_ix);
1329            }
1330            let transaction = Transaction::new_signed_with_payer(
1331                &instructions,
1332                Some(&keypair.pubkey()),
1333                &[&keypair],
1334                recent_blockhash,
1335            );
1336            let encoded_transaction = BASE64_STANDARD.encode(
1337                wincode::serialize(&transaction)
1338                    .map_err(|e| PayError::InternalError(format!("serialize transaction: {e}")))?,
1339            );
1340
1341            match self
1342                .rpc_call(
1343                    endpoint,
1344                    "sendTransaction",
1345                    serde_json::json!([
1346                        encoded_transaction,
1347                        {
1348                            "encoding": "base64",
1349                            "preflightCommitment": "confirmed"
1350                        }
1351                    ]),
1352                )
1353                .await
1354            {
1355                Ok(signature) => {
1356                    transaction_id = Some(signature);
1357                    break;
1358                }
1359                Err(err) => {
1360                    last_error = Some(format!("endpoint={endpoint} sendTransaction: {err}"));
1361                }
1362            }
1363        }
1364        let transaction_id = transaction_id.ok_or_else(|| {
1365            PayError::NetworkError(format!(
1366                "all sol rpc endpoints failed for transfer: {}",
1367                last_error.unwrap_or_else(|| "unknown error".to_string())
1368            ))
1369        })?;
1370
1371        let (amount_value, amount_token) = if transfer_target.token_mint.is_some() {
1372            (transfer_target.amount_lamports, "token-units".to_string())
1373        } else {
1374            (transfer_target.amount_lamports, "lamports".to_string())
1375        };
1376
1377        // Try to fetch precise fee from the transaction; fallback to 5000 lamports estimate
1378        let tx_fee = {
1379            let mut fee_val = 5000u64; // Solana base fee per signature
1380            for ep in &endpoints {
1381                let result: Result<SolGetTransactionResult, _> = self
1382                    .rpc_call(
1383                        ep,
1384                        "getTransaction",
1385                        serde_json::json!([
1386                            transaction_id,
1387                            { "encoding": "json", "maxSupportedTransactionVersion": 0 }
1388                        ]),
1389                    )
1390                    .await;
1391                if let Ok(tx) = result {
1392                    if tx.meta.fee > 0 {
1393                        fee_val = tx.meta.fee;
1394                    }
1395                    break;
1396                }
1397            }
1398            fee_val
1399        };
1400        let fee_amount = Some(Amount {
1401            value: tx_fee,
1402            token: "lamports".to_string(),
1403        });
1404
1405        let now = wallet::now_epoch_seconds();
1406        let record = HistoryRecord {
1407            transaction_id: transaction_id.clone(),
1408            wallet: resolved_wallet_id.clone(),
1409            network: Network::Sol,
1410            direction: Direction::Send,
1411            amount: Amount {
1412                value: amount_value,
1413                token: amount_token.clone(),
1414            },
1415            status: TxStatus::Pending,
1416            onchain_memo: onchain_memo.map(|v| v.to_string()),
1417            local_memo: None,
1418            remote_addr: Some(transfer_target.recipient_address.clone()),
1419            preimage: None,
1420            created_at_epoch_s: now,
1421            confirmed_at_epoch_s: None,
1422            fee: fee_amount.clone(),
1423        };
1424        let _ = self.store.append_transaction_record(&record);
1425
1426        Ok(SendResult {
1427            wallet: resolved_wallet_id,
1428            transaction_id,
1429            amount: Amount {
1430                value: amount_value,
1431                token: amount_token,
1432            },
1433            fee: fee_amount,
1434            preimage: None,
1435        })
1436    }
1437
1438    async fn send_quote(
1439        &self,
1440        wallet: &str,
1441        to: &str,
1442        _mints: Option<&[String]>,
1443    ) -> Result<SendQuoteInfo, PayError> {
1444        let resolved = self.resolve_wallet_id(wallet)?;
1445        let meta = self.load_sol_wallet(&resolved)?;
1446        let endpoints = Self::rpc_endpoints_for_wallet(&meta)?;
1447        let target = Self::parse_transfer_target(to, &endpoints)?;
1448        Ok(SendQuoteInfo {
1449            wallet: resolved,
1450            amount_native: target.amount_lamports,
1451            fee_estimate_native: 5000,
1452            fee_unit: "lamports".to_string(),
1453        })
1454    }
1455
1456    async fn history_list(
1457        &self,
1458        wallet_id: &str,
1459        limit: usize,
1460        offset: usize,
1461    ) -> Result<Vec<HistoryRecord>, PayError> {
1462        let resolved = self.resolve_wallet_id(wallet_id)?;
1463        let _ = self.load_sol_wallet(&resolved)?;
1464        let mut local_records = self.store.load_wallet_transaction_records(&resolved)?;
1465        for record in &mut local_records {
1466            if record.status != TxStatus::Pending || record.network != Network::Sol {
1467                continue;
1468            }
1469            if let Ok(Some(chain_status)) = self
1470                .fetch_chain_status_for_wallet(&resolved, &record.transaction_id)
1471                .await
1472            {
1473                let confirmed_at_epoch_s = if chain_status.status == TxStatus::Confirmed {
1474                    Some(
1475                        record
1476                            .confirmed_at_epoch_s
1477                            .unwrap_or_else(wallet::now_epoch_seconds),
1478                    )
1479                } else {
1480                    None
1481                };
1482                if record.status != chain_status.status
1483                    || record.confirmed_at_epoch_s != confirmed_at_epoch_s
1484                {
1485                    let _ = self.store.update_transaction_record_status(
1486                        &record.transaction_id,
1487                        chain_status.status,
1488                        confirmed_at_epoch_s,
1489                    );
1490                    record.status = chain_status.status;
1491                    record.confirmed_at_epoch_s = confirmed_at_epoch_s;
1492                }
1493            }
1494        }
1495
1496        let fetch_limit = limit
1497            .saturating_add(offset)
1498            .clamp(20, MAX_CHAIN_HISTORY_SCAN);
1499        let chain_records = self
1500            .fetch_chain_history_records(&resolved, fetch_limit)
1501            .await
1502            .unwrap_or_default();
1503
1504        let mut merged_by_id: HashMap<String, HistoryRecord> = HashMap::new();
1505        for record in local_records {
1506            merged_by_id.insert(record.transaction_id.clone(), record);
1507        }
1508        for record in chain_records {
1509            merged_by_id
1510                .entry(record.transaction_id.clone())
1511                .or_insert(record);
1512        }
1513
1514        let mut merged: Vec<HistoryRecord> = merged_by_id.into_values().collect();
1515        merged.sort_by(|a, b| b.created_at_epoch_s.cmp(&a.created_at_epoch_s));
1516
1517        let start = merged.len().min(offset);
1518        let end = merged.len().min(offset.saturating_add(limit));
1519        Ok(merged[start..end].to_vec())
1520    }
1521
1522    async fn history_status(&self, transaction_id: &str) -> Result<HistoryStatusInfo, PayError> {
1523        let local_record = self.store.find_transaction_record_by_id(transaction_id)?;
1524        let local_sol_record = local_record.filter(|r| r.network == Network::Sol);
1525
1526        let chain_record = if let Some(record) = &local_sol_record {
1527            self.fetch_chain_record_for_wallet(&record.wallet, transaction_id)
1528                .await?
1529        } else {
1530            self.fetch_chain_record_across_wallets(transaction_id)
1531                .await?
1532        };
1533
1534        if let Some((chain_item, confirmations)) = chain_record {
1535            let mut item = local_sol_record
1536                .clone()
1537                .unwrap_or_else(|| chain_item.clone());
1538            item.status = chain_item.status;
1539            if item.confirmed_at_epoch_s.is_none() {
1540                item.confirmed_at_epoch_s = chain_item.confirmed_at_epoch_s;
1541            }
1542            if item.onchain_memo.is_none() {
1543                item.onchain_memo = chain_item.onchain_memo;
1544            }
1545            if let Some(local) = local_sol_record.as_ref() {
1546                if local.status != item.status
1547                    || local.confirmed_at_epoch_s != item.confirmed_at_epoch_s
1548                {
1549                    let _ = self.store.update_transaction_record_status(
1550                        transaction_id,
1551                        item.status,
1552                        item.confirmed_at_epoch_s,
1553                    );
1554                }
1555            }
1556            return Ok(HistoryStatusInfo {
1557                transaction_id: transaction_id.to_string(),
1558                status: item.status,
1559                confirmations,
1560                preimage: None,
1561                item: Some(item),
1562            });
1563        }
1564
1565        let chain_status = self
1566            .fetch_chain_status_across_wallets(transaction_id)
1567            .await?;
1568        if let Some(chain_status) = chain_status {
1569            let item = local_sol_record.clone().map(|mut local| {
1570                let confirmed_at_epoch_s = if chain_status.status == TxStatus::Confirmed {
1571                    Some(
1572                        local
1573                            .confirmed_at_epoch_s
1574                            .unwrap_or_else(wallet::now_epoch_seconds),
1575                    )
1576                } else {
1577                    None
1578                };
1579                if local.status != chain_status.status
1580                    || local.confirmed_at_epoch_s != confirmed_at_epoch_s
1581                {
1582                    let _ = self.store.update_transaction_record_status(
1583                        transaction_id,
1584                        chain_status.status,
1585                        confirmed_at_epoch_s,
1586                    );
1587                    local.status = chain_status.status;
1588                    local.confirmed_at_epoch_s = confirmed_at_epoch_s;
1589                }
1590                local
1591            });
1592            return Ok(HistoryStatusInfo {
1593                transaction_id: transaction_id.to_string(),
1594                status: chain_status.status,
1595                confirmations: chain_status.confirmations,
1596                preimage: None,
1597                item,
1598            });
1599        }
1600
1601        if let Some(record) = local_sol_record {
1602            return Ok(HistoryStatusInfo {
1603                transaction_id: record.transaction_id.clone(),
1604                status: record.status,
1605                confirmations: None,
1606                preimage: record.preimage.clone(),
1607                item: Some(record),
1608            });
1609        }
1610
1611        Err(PayError::WalletNotFound(format!(
1612            "transaction {transaction_id} not found"
1613        )))
1614    }
1615
1616    async fn history_sync(
1617        &self,
1618        wallet_id: &str,
1619        limit: usize,
1620    ) -> Result<HistorySyncStats, PayError> {
1621        let resolved = self.resolve_wallet_id(wallet_id)?;
1622        let _ = self.load_sol_wallet(&resolved)?;
1623
1624        let mut local_records = self.store.load_wallet_transaction_records(&resolved)?;
1625        let mut stats = HistorySyncStats::default();
1626
1627        for record in &mut local_records {
1628            if record.network != Network::Sol {
1629                continue;
1630            }
1631            if record.status != TxStatus::Pending {
1632                continue;
1633            }
1634            stats.records_scanned = stats.records_scanned.saturating_add(1);
1635            if let Ok(Some(chain_status)) = self
1636                .fetch_chain_status_for_wallet(&resolved, &record.transaction_id)
1637                .await
1638            {
1639                let confirmed_at_epoch_s = if chain_status.status == TxStatus::Confirmed {
1640                    Some(
1641                        record
1642                            .confirmed_at_epoch_s
1643                            .unwrap_or_else(wallet::now_epoch_seconds),
1644                    )
1645                } else {
1646                    None
1647                };
1648                if record.status != chain_status.status
1649                    || record.confirmed_at_epoch_s != confirmed_at_epoch_s
1650                {
1651                    let _ = self.store.update_transaction_record_status(
1652                        &record.transaction_id,
1653                        chain_status.status,
1654                        confirmed_at_epoch_s,
1655                    );
1656                    record.status = chain_status.status;
1657                    record.confirmed_at_epoch_s = confirmed_at_epoch_s;
1658                    stats.records_updated = stats.records_updated.saturating_add(1);
1659                }
1660            }
1661        }
1662
1663        let fetch_limit = limit.clamp(1, MAX_CHAIN_HISTORY_SCAN);
1664        let chain_records = self
1665            .fetch_chain_history_records(&resolved, fetch_limit)
1666            .await?;
1667        stats.records_scanned = stats.records_scanned.saturating_add(chain_records.len());
1668
1669        let mut local_by_id: HashMap<String, HistoryRecord> = local_records
1670            .into_iter()
1671            .filter(|record| record.network == Network::Sol)
1672            .map(|record| (record.transaction_id.clone(), record))
1673            .collect();
1674
1675        for chain_record in chain_records {
1676            if let Some(existing) = local_by_id.get(&chain_record.transaction_id) {
1677                if existing.status != chain_record.status
1678                    || existing.confirmed_at_epoch_s != chain_record.confirmed_at_epoch_s
1679                {
1680                    let _ = self.store.update_transaction_record_status(
1681                        &chain_record.transaction_id,
1682                        chain_record.status,
1683                        chain_record.confirmed_at_epoch_s,
1684                    );
1685                    stats.records_updated = stats.records_updated.saturating_add(1);
1686                }
1687                continue;
1688            }
1689
1690            let _ = self.store.append_transaction_record(&chain_record);
1691            local_by_id.insert(chain_record.transaction_id.clone(), chain_record);
1692            stats.records_added = stats.records_added.saturating_add(1);
1693        }
1694
1695        Ok(stats)
1696    }
1697}
1698
1699#[cfg(test)]
1700mod tests {
1701    use super::{SolGetTransactionResult, SolProvider, SOL_MEMO_PROGRAM_ID};
1702    use crate::provider::PayProvider;
1703    use crate::store::wallet::{self, WalletMetadata};
1704    use crate::store::StorageBackend;
1705    use crate::types::{Network, WalletCreateRequest};
1706    use solana_sdk::pubkey::Pubkey;
1707    use solana_sdk::signature::{keypair_from_seed_phrase_and_passphrase, Signer};
1708    use std::str::FromStr;
1709    use std::sync::Arc;
1710
1711    #[cfg(feature = "redb")]
1712    fn test_store(data_dir: &str) -> Arc<StorageBackend> {
1713        Arc::new(StorageBackend::Redb(
1714            crate::store::redb_store::RedbStore::new(data_dir),
1715        ))
1716    }
1717
1718    #[test]
1719    fn normalize_endpoint_adds_scheme() {
1720        let endpoint = SolProvider::normalize_rpc_endpoint("127.0.0.1:8899").unwrap();
1721        assert_eq!(endpoint, "http://127.0.0.1:8899");
1722    }
1723
1724    #[test]
1725    fn normalize_rpc_endpoints_from_json_array() {
1726        let endpoints = SolProvider::normalize_rpc_endpoints(
1727            "[\"https://rpc-a.example\",\"rpc-b.example:8899\"]",
1728        )
1729        .unwrap();
1730        assert_eq!(
1731            endpoints,
1732            vec![
1733                "https://rpc-a.example".to_string(),
1734                "http://rpc-b.example:8899".to_string()
1735            ]
1736        );
1737    }
1738
1739    #[test]
1740    fn rpc_endpoints_for_wallet_requires_new_field() {
1741        let meta = WalletMetadata {
1742            id: "w_old0001".to_string(),
1743            network: Network::Sol,
1744            label: None,
1745            mint_url: Some("https://api.mainnet-beta.solana.com".to_string()),
1746            sol_rpc_endpoints: None,
1747            evm_rpc_endpoints: None,
1748            evm_chain_id: None,
1749            seed_secret: Some(
1750                "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string(),
1751            ),
1752            backend: None,
1753            btc_esplora_url: None,
1754            btc_network: None,
1755            btc_address_type: None,
1756            btc_core_url: None,
1757            btc_core_auth_secret: None,
1758            btc_electrum_url: None,
1759            custom_tokens: None,
1760            created_at_epoch_s: wallet::now_epoch_seconds(),
1761            error: None,
1762        };
1763        let err = SolProvider::rpc_endpoints_for_wallet(&meta).unwrap_err();
1764        assert!(err.to_string().contains("missing sol rpc endpoints"));
1765    }
1766
1767    #[test]
1768    fn parse_transfer_target_success() {
1769        let target = SolProvider::parse_transfer_target(
1770            "solana:8nTKRhLQDcnCaS5s8Z4KZPb1i9ddfbfQDeJpw7g4QxjV?amount-lamports=123",
1771            &[],
1772        )
1773        .unwrap();
1774        assert_eq!(target.amount_lamports, 123);
1775        assert_eq!(
1776            target.recipient_address,
1777            "8nTKRhLQDcnCaS5s8Z4KZPb1i9ddfbfQDeJpw7g4QxjV"
1778        );
1779        assert!(target.token_mint.is_none());
1780    }
1781
1782    #[test]
1783    fn parse_transfer_target_missing_amount_fails() {
1784        let error = SolProvider::parse_transfer_target(
1785            "solana:8nTKRhLQDcnCaS5s8Z4KZPb1i9ddfbfQDeJpw7g4QxjV",
1786            &[],
1787        )
1788        .unwrap_err();
1789        assert!(error.to_string().contains("amount"));
1790    }
1791
1792    #[test]
1793    fn parse_transfer_target_with_usdc_token() {
1794        let endpoints = vec!["https://api.mainnet-beta.solana.com".to_string()];
1795        let target = SolProvider::parse_transfer_target(
1796            "solana:8nTKRhLQDcnCaS5s8Z4KZPb1i9ddfbfQDeJpw7g4QxjV?amount-lamports=1000000&token=usdc",
1797            &endpoints,
1798        )
1799        .unwrap();
1800        assert_eq!(target.amount_lamports, 1_000_000);
1801        assert!(target.token_mint.is_some());
1802        assert_eq!(
1803            target.token_mint.unwrap().to_string(),
1804            "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
1805        );
1806    }
1807
1808    #[test]
1809    fn parse_transfer_target_with_devnet_usdc() {
1810        let endpoints = vec!["https://api.devnet.solana.com".to_string()];
1811        let target = SolProvider::parse_transfer_target(
1812            "solana:8nTKRhLQDcnCaS5s8Z4KZPb1i9ddfbfQDeJpw7g4QxjV?amount-lamports=500000&token=usdc",
1813            &endpoints,
1814        )
1815        .unwrap();
1816        assert!(target.token_mint.is_some());
1817        assert_eq!(
1818            target.token_mint.unwrap().to_string(),
1819            "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"
1820        );
1821    }
1822
1823    #[test]
1824    fn parse_transfer_target_with_raw_mint_address() {
1825        let mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
1826        let target = SolProvider::parse_transfer_target(
1827            &format!("solana:8nTKRhLQDcnCaS5s8Z4KZPb1i9ddfbfQDeJpw7g4QxjV?amount-lamports=100&token={mint}"),
1828            &[],
1829        )
1830        .unwrap();
1831        assert_eq!(target.token_mint.unwrap().to_string(), mint);
1832    }
1833
1834    #[test]
1835    fn derive_ata_deterministic() {
1836        let wallet = Pubkey::from_str("8nTKRhLQDcnCaS5s8Z4KZPb1i9ddfbfQDeJpw7g4QxjV").unwrap();
1837        let mint = Pubkey::from_str("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v").unwrap();
1838        let ata1 = SolProvider::derive_ata(&wallet, &mint).unwrap();
1839        let ata2 = SolProvider::derive_ata(&wallet, &mint).unwrap();
1840        assert_eq!(ata1, ata2);
1841        // ATA should be different from both wallet and mint
1842        assert_ne!(ata1, wallet);
1843        assert_ne!(ata1, mint);
1844    }
1845
1846    #[test]
1847    fn spl_transfer_instruction_encoding() {
1848        let source = Pubkey::from_str("8nTKRhLQDcnCaS5s8Z4KZPb1i9ddfbfQDeJpw7g4QxjV").unwrap();
1849        let mint = Pubkey::from_str("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v").unwrap();
1850        let dest = Pubkey::from_str("7YWbWN4E6TQVYAPEZyyRhhmQvawbcSbPVFepW1uCNooe").unwrap();
1851        let authority = Pubkey::from_str("8nTKRhLQDcnCaS5s8Z4KZPb1i9ddfbfQDeJpw7g4QxjV").unwrap();
1852        let ix = SolProvider::build_spl_transfer_instruction(
1853            &source, &mint, &dest, &authority, 1_000_000, 6,
1854        )
1855        .unwrap();
1856        // data: 1 byte discriminator + 8 bytes amount + 1 byte decimals = 10 bytes
1857        assert_eq!(ix.data.len(), 10);
1858        assert_eq!(ix.data[0], 12); // transfer_checked discriminator
1859        assert_eq!(ix.data[9], 6); // decimals
1860                                   // 4 accounts: source, mint, dest, authority
1861        assert_eq!(ix.accounts.len(), 4);
1862    }
1863
1864    #[test]
1865    fn keypair_from_mnemonic_secret() {
1866        let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
1867        let keypair = SolProvider::keypair_from_seed_secret(mnemonic).unwrap();
1868        let expected = keypair_from_seed_phrase_and_passphrase(mnemonic, "").unwrap();
1869        assert_eq!(keypair.pubkey(), expected.pubkey());
1870    }
1871
1872    #[test]
1873    fn keypair_from_non_mnemonic_secret_fails() {
1874        let err = SolProvider::keypair_from_seed_secret("not-a-valid-mnemonic").unwrap_err();
1875        assert!(err.to_string().contains("expected BIP39 mnemonic words"));
1876    }
1877
1878    #[test]
1879    fn extract_memo_from_transaction_returns_memo_text() {
1880        let memo_text = "order:ord_123";
1881        let tx_value = serde_json::json!({
1882            "meta": {
1883                "preBalances": [10, 0],
1884                "postBalances": [9, 1],
1885                "err": null
1886            },
1887            "transaction": {
1888                "message": {
1889                    "accountKeys": [
1890                        "11111111111111111111111111111111",
1891                        SOL_MEMO_PROGRAM_ID
1892                    ],
1893                    "instructions": [
1894                        {
1895                            "programIdIndex": 1,
1896                            "data": bs58::encode(memo_text.as_bytes()).into_string()
1897                        }
1898                    ]
1899                }
1900            },
1901            "blockTime": 1772808557u64
1902        });
1903        let tx: SolGetTransactionResult = serde_json::from_value(tx_value).unwrap();
1904        let extracted = SolProvider::extract_memo_from_transaction(&tx);
1905        assert_eq!(extracted.as_deref(), Some(memo_text));
1906    }
1907
1908    #[test]
1909    fn extract_memo_from_transaction_returns_none_when_missing() {
1910        let tx_value = serde_json::json!({
1911            "meta": {
1912                "preBalances": [10, 0],
1913                "postBalances": [9, 1],
1914                "err": null
1915            },
1916            "transaction": {
1917                "message": {
1918                    "accountKeys": [
1919                        "11111111111111111111111111111111"
1920                    ],
1921                    "instructions": [
1922                        {
1923                            "programIdIndex": 0,
1924                            "data": bs58::encode(b"not-memo").into_string()
1925                        }
1926                    ]
1927                }
1928            },
1929            "blockTime": 1772808557u64
1930        });
1931        let tx: SolGetTransactionResult = serde_json::from_value(tx_value).unwrap();
1932        assert!(SolProvider::extract_memo_from_transaction(&tx).is_none());
1933    }
1934
1935    #[cfg(feature = "redb")]
1936    #[tokio::test]
1937    async fn list_wallets_tolerates_invalid_secret() {
1938        let tmp = tempfile::tempdir().unwrap();
1939        let data_dir = tmp.path().to_string_lossy().to_string();
1940        let provider = SolProvider::new(&data_dir, test_store(&data_dir));
1941        let endpoint = "https://api.devnet.solana.com".to_string();
1942
1943        let valid_mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
1944        let valid_address = keypair_from_seed_phrase_and_passphrase(valid_mnemonic, "")
1945            .unwrap()
1946            .pubkey()
1947            .to_string();
1948
1949        wallet::save_wallet_metadata(
1950            &data_dir,
1951            &WalletMetadata {
1952                id: "w_good0001".to_string(),
1953                network: Network::Sol,
1954                label: Some("good".to_string()),
1955                mint_url: Some(endpoint.clone()),
1956                sol_rpc_endpoints: Some(vec![endpoint.clone()]),
1957                evm_rpc_endpoints: None,
1958                evm_chain_id: None,
1959                seed_secret: Some(valid_mnemonic.to_string()),
1960                backend: None,
1961                btc_esplora_url: None,
1962                btc_network: None,
1963                btc_address_type: None,
1964                btc_core_url: None,
1965                btc_core_auth_secret: None,
1966                btc_electrum_url: None,
1967                custom_tokens: None,
1968                created_at_epoch_s: wallet::now_epoch_seconds(),
1969                error: None,
1970            },
1971        )
1972        .unwrap();
1973
1974        wallet::save_wallet_metadata(
1975            &data_dir,
1976            &WalletMetadata {
1977                id: "w_bad0002".to_string(),
1978                network: Network::Sol,
1979                label: Some("bad".to_string()),
1980                mint_url: Some(endpoint),
1981                sol_rpc_endpoints: Some(vec!["https://api.devnet.solana.com".to_string()]),
1982                evm_rpc_endpoints: None,
1983                evm_chain_id: None,
1984                seed_secret: Some("not-a-valid-mnemonic".to_string()),
1985                backend: None,
1986                btc_esplora_url: None,
1987                btc_network: None,
1988                btc_address_type: None,
1989                btc_core_url: None,
1990                btc_core_auth_secret: None,
1991                btc_electrum_url: None,
1992                custom_tokens: None,
1993                created_at_epoch_s: wallet::now_epoch_seconds(),
1994                error: None,
1995            },
1996        )
1997        .unwrap();
1998
1999        let wallets = provider.list_wallets().await.unwrap();
2000        assert_eq!(wallets.len(), 2);
2001        let good = wallets.iter().find(|w| w.id == "w_good0001").unwrap();
2002        assert_eq!(good.address, valid_address);
2003        let bad = wallets.iter().find(|w| w.id == "w_bad0002").unwrap();
2004        assert_eq!(bad.address, "invalid:sol-wallet-secret");
2005    }
2006
2007    #[cfg(feature = "redb")]
2008    #[tokio::test]
2009    async fn send_quote_resolves_wallet_identifier() {
2010        let tmp = tempfile::tempdir().unwrap();
2011        let data_dir = tmp.path().to_string_lossy().to_string();
2012        let provider = SolProvider::new(&data_dir, test_store(&data_dir));
2013        let mnemonic =
2014            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
2015
2016        let wallet = provider
2017            .create_wallet(&WalletCreateRequest {
2018                label: "quote-wallet".to_string(),
2019                mint_url: None,
2020                rpc_endpoints: vec!["https://api.devnet.solana.com".to_string()],
2021                chain_id: None,
2022                mnemonic_secret: Some(mnemonic.to_string()),
2023                btc_esplora_url: None,
2024                btc_network: None,
2025                btc_address_type: None,
2026                btc_backend: None,
2027                btc_core_url: None,
2028                btc_core_auth_secret: None,
2029                btc_electrum_url: None,
2030            })
2031            .await
2032            .expect("create wallet");
2033
2034        let quote = provider
2035            .send_quote(
2036                "",
2037                &format!("solana:{}?amount=1000&token=native", wallet.address),
2038                None,
2039            )
2040            .await
2041            .expect("send quote should resolve single wallet");
2042
2043        assert_eq!(quote.wallet, wallet.id);
2044        assert_eq!(quote.amount_native, 1000);
2045        assert_eq!(quote.fee_unit, "lamports");
2046    }
2047}