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