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 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 } else {
192 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 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 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 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![], })
446 }
447
448 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 let mut data = Vec::with_capacity(10);
461 data.push(12u8); 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 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 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 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 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); 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 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 let tx_fee = {
1376 let mut fee_val = 5000u64; 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 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 assert_eq!(ix.data.len(), 10);
1854 assert_eq!(ix.data[0], 12); assert_eq!(ix.data[9], 6); 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}