Skip to main content

agent_first_pay/provider/
sol.rs

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