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