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