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