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