mpc_wallet_core/chain/
solana.rs

1//! # Solana Chain Adapter
2//!
3//! Adapter for Solana with support for:
4//! - Legacy and versioned transactions
5//! - Priority fee estimation
6//! - Associated token account handling
7//! - Compute unit optimization
8//!
9//! ## Example
10//!
11//! ```rust,ignore
12//! use mpc_wallet_core::chain::solana::{SolanaAdapter, SolanaConfig};
13//!
14//! let config = SolanaConfig::mainnet();
15//! let adapter = SolanaAdapter::new(config)?;
16//!
17//! let balance = adapter.get_balance("...base58_address...").await?;
18//! ```
19
20use super::{
21    Balance, ChainAdapter, ChainId, GasPrice, GasPrices, RpcClient, SignedTx, TxHash, TxParams,
22    TxPriority, TxReceipt, TxStatus, TxSummary, UnsignedTx,
23};
24use crate::{Error, Result, Signature};
25use async_trait::async_trait;
26use serde::{Deserialize, Serialize};
27// TODO: Migrate from deprecated `system_instruction` to `solana_system_interface::instruction`
28// when updating to solana-sdk 3.x. See: https://github.com/anza-xyz/solana-sdk
29#[allow(deprecated)]
30use solana_sdk::{
31    compute_budget::ComputeBudgetInstruction,
32    hash::Hash,
33    instruction::{AccountMeta, Instruction},
34    message::Message,
35    pubkey::Pubkey,
36    signature::Signature as SolanaSignature,
37    system_instruction,
38    transaction::Transaction,
39};
40use std::str::FromStr;
41
42// Use bincode 1.x for Solana transaction serialization
43use bincode1 as bincode;
44
45// ============================================================================
46// Constants
47// ============================================================================
48
49/// Solana System Program ID
50pub const SYSTEM_PROGRAM_ID: &str = "11111111111111111111111111111111";
51
52/// SPL Token Program ID
53pub const TOKEN_PROGRAM_ID: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
54
55/// SPL Associated Token Account Program ID
56pub const ATA_PROGRAM_ID: &str = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL";
57
58// ============================================================================
59// Configuration
60// ============================================================================
61
62/// Configuration for Solana adapter
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct SolanaConfig {
65    /// Chain identifier (conventional, not used on-chain)
66    pub chain_id: ChainId,
67    /// RPC endpoint URLs (for failover)
68    pub rpc_urls: Vec<String>,
69    /// Block explorer URL (optional)
70    pub explorer_url: Option<String>,
71    /// Commitment level for transactions
72    #[serde(default)]
73    pub commitment: SolanaCommitment,
74    /// Whether to use versioned transactions
75    pub use_versioned_transactions: bool,
76}
77
78/// Solana commitment level wrapper
79#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "lowercase")]
81pub enum SolanaCommitment {
82    Processed,
83    #[default]
84    Confirmed,
85    Finalized,
86}
87
88impl SolanaCommitment {
89    /// Convert to string for RPC calls
90    pub fn as_str(&self) -> &'static str {
91        match self {
92            SolanaCommitment::Processed => "processed",
93            SolanaCommitment::Confirmed => "confirmed",
94            SolanaCommitment::Finalized => "finalized",
95        }
96    }
97}
98
99impl SolanaConfig {
100    /// Create config for Solana Mainnet
101    pub fn mainnet() -> Self {
102        Self {
103            chain_id: ChainId::SOLANA_MAINNET,
104            rpc_urls: vec![
105                "https://api.mainnet-beta.solana.com".to_string(),
106                "https://solana-api.projectserum.com".to_string(),
107            ],
108            explorer_url: Some("https://explorer.solana.com".to_string()),
109            commitment: SolanaCommitment::Confirmed,
110            use_versioned_transactions: true,
111        }
112    }
113
114    /// Create config for Solana Devnet
115    pub fn devnet() -> Self {
116        Self {
117            chain_id: ChainId::SOLANA_DEVNET,
118            rpc_urls: vec!["https://api.devnet.solana.com".to_string()],
119            explorer_url: Some("https://explorer.solana.com?cluster=devnet".to_string()),
120            commitment: SolanaCommitment::Confirmed,
121            use_versioned_transactions: true,
122        }
123    }
124
125    /// Create config for Solana Testnet
126    pub fn testnet() -> Self {
127        Self {
128            chain_id: ChainId::SOLANA_TESTNET,
129            rpc_urls: vec!["https://api.testnet.solana.com".to_string()],
130            explorer_url: Some("https://explorer.solana.com?cluster=testnet".to_string()),
131            commitment: SolanaCommitment::Confirmed,
132            use_versioned_transactions: true,
133        }
134    }
135
136    /// Create a custom config
137    pub fn custom(rpc_urls: Vec<String>) -> Self {
138        Self {
139            chain_id: ChainId::SOLANA_MAINNET,
140            rpc_urls,
141            explorer_url: None,
142            commitment: SolanaCommitment::Confirmed,
143            use_versioned_transactions: true,
144        }
145    }
146
147    /// Set explorer URL
148    pub fn with_explorer(mut self, url: impl Into<String>) -> Self {
149        self.explorer_url = Some(url.into());
150        self
151    }
152
153    /// Set commitment level
154    pub fn with_commitment(mut self, commitment: SolanaCommitment) -> Self {
155        self.commitment = commitment;
156        self
157    }
158
159    /// Enable/disable versioned transactions
160    pub fn with_versioned_transactions(mut self, enabled: bool) -> Self {
161        self.use_versioned_transactions = enabled;
162        self
163    }
164}
165
166// ============================================================================
167// Solana Adapter
168// ============================================================================
169
170/// Solana chain adapter implementation
171#[derive(Debug, Clone)]
172pub struct SolanaAdapter {
173    config: SolanaConfig,
174    rpc: RpcClient,
175}
176
177impl SolanaAdapter {
178    /// Create a new Solana adapter
179    pub fn new(config: SolanaConfig) -> Result<Self> {
180        let rpc = RpcClient::new(config.rpc_urls.clone())?;
181        Ok(Self { config, rpc })
182    }
183
184    /// Get the configuration
185    pub fn config(&self) -> &SolanaConfig {
186        &self.config
187    }
188
189    /// Get RPC client reference
190    pub fn rpc(&self) -> &RpcClient {
191        &self.rpc
192    }
193
194    /// Get the recent blockhash
195    pub async fn get_recent_blockhash(&self) -> Result<Hash> {
196        #[derive(Deserialize)]
197        struct BlockhashResponse {
198            blockhash: String,
199        }
200
201        #[derive(Deserialize)]
202        struct RpcResponse {
203            value: BlockhashResponse,
204        }
205
206        let response: RpcResponse = self
207            .rpc
208            .request(
209                "getLatestBlockhash",
210                serde_json::json!([{
211                    "commitment": self.config.commitment.as_str()
212                }]),
213            )
214            .await?;
215
216        Hash::from_str(&response.value.blockhash)
217            .map_err(|e| Error::ChainError(format!("Invalid blockhash: {}", e)))
218    }
219
220    /// Get priority fees using getRecentPrioritizationFees
221    async fn get_priority_fees(&self) -> Result<PriorityFees> {
222        #[derive(Deserialize)]
223        struct FeeEntry {
224            #[serde(rename = "prioritizationFee")]
225            prioritization_fee: u64,
226        }
227
228        let response: Vec<FeeEntry> = self
229            .rpc
230            .request("getRecentPrioritizationFees", serde_json::json!([]))
231            .await
232            .unwrap_or_default();
233
234        if response.is_empty() {
235            return Ok(PriorityFees::default());
236        }
237
238        let mut fees: Vec<u64> = response.iter().map(|e| e.prioritization_fee).collect();
239        fees.sort();
240
241        let len = fees.len();
242        Ok(PriorityFees {
243            low: fees.get(len / 4).copied().unwrap_or(0),
244            medium: fees.get(len / 2).copied().unwrap_or(1000),
245            high: fees.get(len * 3 / 4).copied().unwrap_or(10000),
246        })
247    }
248
249    /// Get minimum rent exemption for an account
250    pub async fn get_minimum_balance_for_rent_exemption(&self, data_len: usize) -> Result<u64> {
251        let result: u64 = self
252            .rpc
253            .request(
254                "getMinimumBalanceForRentExemption",
255                serde_json::json!([data_len]),
256            )
257            .await?;
258
259        Ok(result)
260    }
261
262    /// Get token accounts for an owner
263    pub async fn get_token_accounts(&self, owner: &str) -> Result<Vec<TokenAccount>> {
264        let owner_pubkey = Pubkey::from_str(owner)
265            .map_err(|e| Error::InvalidConfig(format!("Invalid owner address: {}", e)))?;
266
267        #[derive(Deserialize)]
268        struct AccountData {
269            pubkey: String,
270            account: AccountInfo,
271        }
272
273        #[derive(Deserialize)]
274        struct AccountInfo {
275            data: ParsedData,
276        }
277
278        #[derive(Deserialize)]
279        struct ParsedData {
280            parsed: ParsedInfo,
281        }
282
283        #[derive(Deserialize)]
284        struct ParsedInfo {
285            info: TokenInfo,
286        }
287
288        #[derive(Deserialize)]
289        struct TokenInfo {
290            mint: String,
291            #[serde(rename = "tokenAmount")]
292            token_amount: TokenAmount,
293        }
294
295        #[derive(Deserialize)]
296        struct TokenAmount {
297            amount: String,
298            decimals: u8,
299            #[serde(rename = "uiAmountString")]
300            ui_amount_string: String,
301        }
302
303        #[derive(Deserialize)]
304        struct RpcResponse {
305            value: Vec<AccountData>,
306        }
307
308        let response: RpcResponse = self
309            .rpc
310            .request(
311                "getTokenAccountsByOwner",
312                serde_json::json!([
313                    owner_pubkey.to_string(),
314                    {"programId": TOKEN_PROGRAM_ID},
315                    {"encoding": "jsonParsed"}
316                ]),
317            )
318            .await?;
319
320        Ok(response
321            .value
322            .into_iter()
323            .map(|a| TokenAccount {
324                address: a.pubkey,
325                mint: a.account.data.parsed.info.mint,
326                balance: a.account.data.parsed.info.token_amount.amount,
327                decimals: a.account.data.parsed.info.token_amount.decimals,
328                formatted_balance: a.account.data.parsed.info.token_amount.ui_amount_string,
329            })
330            .collect())
331    }
332
333    /// Get or create associated token account address
334    pub fn get_associated_token_address(&self, owner: &str, mint: &str) -> Result<String> {
335        let owner_pubkey = Pubkey::from_str(owner)
336            .map_err(|e| Error::InvalidConfig(format!("Invalid owner: {}", e)))?;
337        let mint_pubkey = Pubkey::from_str(mint)
338            .map_err(|e| Error::InvalidConfig(format!("Invalid mint: {}", e)))?;
339
340        let ata =
341            spl_associated_token_account::get_associated_token_address(&owner_pubkey, &mint_pubkey);
342
343        Ok(ata.to_string())
344    }
345
346    /// Build instructions for creating an ATA if needed
347    pub async fn build_create_ata_instruction(
348        &self,
349        payer: &str,
350        owner: &str,
351        mint: &str,
352    ) -> Result<Option<Instruction>> {
353        let ata = self.get_associated_token_address(owner, mint)?;
354
355        // Check if ATA exists
356        let account_info = self.get_account_info(&ata).await?;
357
358        if account_info.is_none() {
359            let payer_pubkey = Pubkey::from_str(payer)
360                .map_err(|e| Error::InvalidConfig(format!("Invalid payer: {}", e)))?;
361            let owner_pubkey = Pubkey::from_str(owner)
362                .map_err(|e| Error::InvalidConfig(format!("Invalid owner: {}", e)))?;
363            let mint_pubkey = Pubkey::from_str(mint)
364                .map_err(|e| Error::InvalidConfig(format!("Invalid mint: {}", e)))?;
365
366            let instruction =
367                spl_associated_token_account::instruction::create_associated_token_account(
368                    &payer_pubkey,
369                    &owner_pubkey,
370                    &mint_pubkey,
371                    &spl_token::id(),
372                );
373
374            Ok(Some(instruction))
375        } else {
376            Ok(None)
377        }
378    }
379
380    /// Get account info
381    async fn get_account_info(&self, address: &str) -> Result<Option<AccountInfoResponse>> {
382        #[derive(Deserialize)]
383        struct RpcResponse {
384            value: Option<AccountInfoResponse>,
385        }
386
387        let response: RpcResponse = self
388            .rpc
389            .request(
390                "getAccountInfo",
391                serde_json::json!([
392                    address,
393                    {"encoding": "base64"}
394                ]),
395            )
396            .await?;
397
398        Ok(response.value)
399    }
400
401    /// Parse lamports to SOL string
402    fn lamports_to_sol(lamports: u64) -> String {
403        let sol = lamports as f64 / 1_000_000_000.0;
404        format!("{:.9}", sol)
405            .trim_end_matches('0')
406            .trim_end_matches('.')
407            .to_string()
408    }
409
410    /// Parse SOL string to lamports
411    fn sol_to_lamports(sol: &str) -> Result<u64> {
412        let value: f64 = sol
413            .parse()
414            .map_err(|_| Error::InvalidConfig(format!("Invalid SOL value: {}", sol)))?;
415
416        Ok((value * 1_000_000_000.0) as u64)
417    }
418
419    /// Build a simple SOL transfer transaction
420    fn build_transfer_instructions(
421        &self,
422        from: &Pubkey,
423        to: &Pubkey,
424        lamports: u64,
425        priority_fee: u64,
426        compute_units: u32,
427    ) -> Vec<Instruction> {
428        let mut instructions = Vec::new();
429
430        // Add compute budget instructions for priority fee
431        if priority_fee > 0 {
432            instructions.push(ComputeBudgetInstruction::set_compute_unit_price(
433                priority_fee,
434            ));
435        }
436
437        // Add compute unit limit
438        instructions.push(ComputeBudgetInstruction::set_compute_unit_limit(
439            compute_units,
440        ));
441
442        // Add transfer instruction
443        instructions.push(system_instruction::transfer(from, to, lamports));
444
445        instructions
446    }
447}
448
449#[async_trait]
450impl ChainAdapter for SolanaAdapter {
451    fn chain_id(&self) -> ChainId {
452        self.config.chain_id
453    }
454
455    fn native_symbol(&self) -> &str {
456        "SOL"
457    }
458
459    fn native_decimals(&self) -> u8 {
460        9
461    }
462
463    async fn get_balance(&self, address: &str) -> Result<Balance> {
464        let pubkey = Pubkey::from_str(address)
465            .map_err(|e| Error::InvalidConfig(format!("Invalid address: {}", e)))?;
466
467        #[derive(Deserialize)]
468        struct BalanceResponse {
469            value: u64,
470        }
471
472        let result: BalanceResponse = self
473            .rpc
474            .request("getBalance", serde_json::json!([pubkey.to_string()]))
475            .await?;
476
477        Ok(Balance::new(result.value.to_string(), 9, "SOL"))
478    }
479
480    async fn get_nonce(&self, _address: &str) -> Result<u64> {
481        // Solana doesn't have a traditional nonce - we use recent blockhash instead
482        // Return 0 as a placeholder
483        Ok(0)
484    }
485
486    async fn build_transaction(&self, params: TxParams) -> Result<UnsignedTx> {
487        let from_pubkey = Pubkey::from_str(&params.from)
488            .map_err(|e| Error::InvalidConfig(format!("Invalid from address: {}", e)))?;
489        let to_pubkey = Pubkey::from_str(&params.to)
490            .map_err(|e| Error::InvalidConfig(format!("Invalid to address: {}", e)))?;
491
492        // Parse value
493        let lamports = Self::sol_to_lamports(&params.value)?;
494
495        // Get priority fees
496        let priority_fees = self.get_priority_fees().await?;
497        let priority_fee = match params.priority {
498            TxPriority::Low => priority_fees.low,
499            TxPriority::Medium => priority_fees.medium,
500            TxPriority::High | TxPriority::Urgent => priority_fees.high,
501        };
502
503        // Get recent blockhash (used for transaction validity)
504        let _recent_blockhash = self.get_recent_blockhash().await?;
505
506        // Compute units
507        let compute_units = params.gas_limit.unwrap_or(200_000) as u32;
508
509        // Build instructions
510        let instructions = if let Some(data) = &params.data {
511            // Custom instruction - interpret data as program instruction
512            let mut ixs = Vec::new();
513
514            if priority_fee > 0 {
515                ixs.push(ComputeBudgetInstruction::set_compute_unit_price(
516                    priority_fee,
517                ));
518            }
519            ixs.push(ComputeBudgetInstruction::set_compute_unit_limit(
520                compute_units,
521            ));
522
523            // Create instruction with the data (assume `to` is program ID)
524            ixs.push(Instruction {
525                program_id: to_pubkey,
526                accounts: vec![AccountMeta::new(from_pubkey, true)],
527                data: data.clone(),
528            });
529
530            ixs
531        } else {
532            // Simple SOL transfer
533            self.build_transfer_instructions(
534                &from_pubkey,
535                &to_pubkey,
536                lamports,
537                priority_fee,
538                compute_units,
539            )
540        };
541
542        // Build transaction
543        let message = Message::new(&instructions, Some(&from_pubkey));
544        let tx = Transaction::new_unsigned(message);
545
546        // Serialize for signing - the message bytes that need to be signed
547        let signing_payload = tx.message_data();
548
549        // Serialize full transaction using bincode 1.x API (re-exported from solana-sdk)
550        let raw_tx = bincode::serialize(&tx)
551            .map_err(|e| Error::ChainError(format!("Failed to serialize transaction: {}", e)))?;
552
553        // Calculate estimated fee
554        let estimated_fee = 5000 + (priority_fee * compute_units as u64 / 1_000_000);
555        let fee_formatted = Self::lamports_to_sol(estimated_fee);
556
557        let summary = TxSummary {
558            tx_type: if params.data.is_some() {
559                "Program Call".to_string()
560            } else {
561                "Transfer".to_string()
562            },
563            from: params.from.clone(),
564            to: params.to.clone(),
565            value: format!("{} SOL", params.value),
566            estimated_fee: format!("{} SOL", fee_formatted),
567            details: Some(format!("Priority fee: {} micro-lamports/CU", priority_fee)),
568        };
569
570        Ok(UnsignedTx {
571            chain_id: self.config.chain_id,
572            signing_payload,
573            raw_tx,
574            summary,
575        })
576    }
577
578    async fn broadcast(&self, signed_tx: &SignedTx) -> Result<TxHash> {
579        let encoded = bs58::encode(&signed_tx.raw_tx).into_string();
580
581        let result: String = self
582            .rpc
583            .request(
584                "sendTransaction",
585                serde_json::json!([
586                    encoded,
587                    {
588                        "encoding": "base58",
589                        "skipPreflight": false,
590                        "preflightCommitment": self.config.commitment.as_str()
591                    }
592                ]),
593            )
594            .await?;
595
596        let explorer_url = self.explorer_tx_url(&result);
597
598        Ok(TxHash {
599            hash: result,
600            explorer_url,
601        })
602    }
603
604    fn derive_address(&self, public_key: &[u8]) -> Result<String> {
605        // For Solana, we typically use Ed25519 public keys (32 bytes)
606        // But MPC uses secp256k1, so we need to handle this differently
607        //
608        // Option 1: Use the secp256k1 public key directly (not standard Solana)
609        // Option 2: Derive an Ed25519 key from secp256k1 (complex)
610        // Option 3: Use program-derived addresses (PDA)
611        //
612        // For this implementation, we'll use the compressed secp256k1 public key
613        // and create a deterministic address from it using a hash
614
615        if public_key.len() == 32 {
616            // Already a 32-byte key (could be Ed25519)
617            let pubkey = Pubkey::new_from_array(
618                public_key
619                    .try_into()
620                    .map_err(|_| Error::Crypto("Invalid public key length".into()))?,
621            );
622            Ok(pubkey.to_string())
623        } else if public_key.len() == 33 {
624            // Compressed secp256k1 - hash to 32 bytes
625            use sha2::{Digest, Sha256};
626            let mut hasher = Sha256::new();
627            hasher.update(public_key);
628            let hash = hasher.finalize();
629            let pubkey = Pubkey::new_from_array(hash.into());
630            Ok(pubkey.to_string())
631        } else if public_key.len() == 64 || public_key.len() == 65 {
632            // Uncompressed secp256k1 - hash to 32 bytes
633            use sha2::{Digest, Sha256};
634            let key_bytes = if public_key.len() == 65 {
635                &public_key[1..] // Skip 0x04 prefix
636            } else {
637                public_key
638            };
639            let mut hasher = Sha256::new();
640            hasher.update(key_bytes);
641            let hash = hasher.finalize();
642            let pubkey = Pubkey::new_from_array(hash.into());
643            Ok(pubkey.to_string())
644        } else {
645            Err(Error::Crypto(format!(
646                "Invalid public key length: {}",
647                public_key.len()
648            )))
649        }
650    }
651
652    async fn get_gas_prices(&self) -> Result<GasPrices> {
653        let priority_fees = self.get_priority_fees().await?;
654
655        Ok(GasPrices {
656            low: GasPrice {
657                max_fee: priority_fees.low as u128,
658                max_priority_fee: priority_fees.low as u128,
659                estimated_wait_secs: Some(30),
660            },
661            medium: GasPrice {
662                max_fee: priority_fees.medium as u128,
663                max_priority_fee: priority_fees.medium as u128,
664                estimated_wait_secs: Some(10),
665            },
666            high: GasPrice {
667                max_fee: priority_fees.high as u128,
668                max_priority_fee: priority_fees.high as u128,
669                estimated_wait_secs: Some(5),
670            },
671            base_fee: Some(5000), // Base fee is 5000 lamports per signature
672        })
673    }
674
675    async fn estimate_gas(&self, params: &TxParams) -> Result<u64> {
676        // Estimate compute units needed
677        // Simple transfer: ~200 CU
678        // Token transfer: ~20,000 CU
679        // Complex operations: 200,000+ CU
680
681        if params.data.is_some() {
682            Ok(200_000) // Default for program calls
683        } else {
684            Ok(200) // Simple SOL transfer
685        }
686    }
687
688    async fn wait_for_confirmation(&self, tx_hash: &str, timeout_secs: u64) -> Result<TxReceipt> {
689        let start = std::time::Instant::now();
690        let timeout = std::time::Duration::from_secs(timeout_secs);
691
692        loop {
693            if start.elapsed() > timeout {
694                return Err(Error::Timeout(format!(
695                    "Transaction {} not confirmed within {} seconds",
696                    tx_hash, timeout_secs
697                )));
698            }
699
700            #[derive(Deserialize)]
701            struct TxResponse {
702                value: Option<TxInfo>,
703            }
704
705            #[derive(Deserialize)]
706            struct TxInfo {
707                slot: u64,
708                meta: Option<TxMeta>,
709            }
710
711            #[derive(Deserialize)]
712            struct TxMeta {
713                err: Option<serde_json::Value>,
714                fee: u64,
715                #[serde(rename = "computeUnitsConsumed")]
716                compute_units_consumed: Option<u64>,
717            }
718
719            let response: TxResponse = self
720                .rpc
721                .request(
722                    "getTransaction",
723                    serde_json::json!([
724                        tx_hash,
725                        {
726                            "encoding": "json",
727                            "commitment": self.config.commitment.as_str()
728                        }
729                    ]),
730                )
731                .await?;
732
733            if let Some(info) = response.value {
734                let status = if info.meta.as_ref().and_then(|m| m.err.as_ref()).is_some() {
735                    TxStatus::Failed
736                } else {
737                    TxStatus::Success
738                };
739
740                return Ok(TxReceipt {
741                    tx_hash: tx_hash.to_string(),
742                    block_number: info.slot,
743                    status,
744                    gas_used: info.meta.as_ref().and_then(|m| m.compute_units_consumed),
745                    effective_gas_price: info.meta.as_ref().map(|m| m.fee as u128),
746                });
747            }
748
749            tokio::time::sleep(std::time::Duration::from_millis(500)).await;
750        }
751    }
752
753    fn is_valid_address(&self, address: &str) -> bool {
754        Pubkey::from_str(address).is_ok()
755    }
756
757    fn explorer_tx_url(&self, tx_hash: &str) -> Option<String> {
758        self.config.explorer_url.as_ref().map(|base| {
759            if base.contains("?cluster=") {
760                format!("{}&tx={}", base, tx_hash)
761            } else {
762                format!("{}/tx/{}", base, tx_hash)
763            }
764        })
765    }
766
767    fn explorer_address_url(&self, address: &str) -> Option<String> {
768        self.config.explorer_url.as_ref().map(|base| {
769            if base.contains("?cluster=") {
770                format!("{}&address={}", base, address)
771            } else {
772                format!("{}/address/{}", base, address)
773            }
774        })
775    }
776
777    fn finalize_transaction(
778        &self,
779        unsigned_tx: &UnsignedTx,
780        signature: &Signature,
781    ) -> Result<SignedTx> {
782        // Deserialize the unsigned transaction using bincode 1.x API
783        let mut tx: Transaction = bincode::deserialize(&unsigned_tx.raw_tx)
784            .map_err(|e| Error::ChainError(format!("Failed to deserialize transaction: {}", e)))?;
785
786        // Create Solana signature from ECDSA signature
787        // Note: This is a compatibility layer - Solana natively uses Ed25519
788        // For MPC with secp256k1, we need to handle this specially
789        let mut sig_bytes = [0u8; 64];
790        sig_bytes[..32].copy_from_slice(&signature.r);
791        sig_bytes[32..].copy_from_slice(&signature.s);
792
793        let solana_sig = SolanaSignature::from(sig_bytes);
794
795        // Add signature to transaction
796        tx.signatures = vec![solana_sig];
797
798        // Serialize signed transaction using bincode 1.x API
799        let raw_tx = bincode::serialize(&tx).map_err(|e| {
800            Error::ChainError(format!("Failed to serialize signed transaction: {}", e))
801        })?;
802
803        Ok(SignedTx {
804            chain_id: self.config.chain_id,
805            raw_tx,
806            tx_hash: bs58::encode(&sig_bytes).into_string(),
807        })
808    }
809}
810
811// ============================================================================
812// Supporting Types
813// ============================================================================
814
815/// Priority fees for Solana transactions
816#[derive(Debug, Clone, Default)]
817struct PriorityFees {
818    low: u64,
819    medium: u64,
820    high: u64,
821}
822
823/// Token account information
824#[derive(Debug, Clone, Serialize, Deserialize)]
825pub struct TokenAccount {
826    /// Token account address
827    pub address: String,
828    /// Token mint address
829    pub mint: String,
830    /// Raw balance (smallest unit)
831    pub balance: String,
832    /// Token decimals
833    pub decimals: u8,
834    /// Human-readable balance
835    pub formatted_balance: String,
836}
837
838/// Account info response from RPC
839#[derive(Debug, Deserialize)]
840#[allow(dead_code)]
841struct AccountInfoResponse {
842    lamports: u64,
843    owner: String,
844    data: Vec<String>,
845    executable: bool,
846    #[serde(rename = "rentEpoch")]
847    rent_epoch: u64,
848}
849
850/// Builder for SPL token transfers
851pub struct TokenTransferBuilder {
852    from: String,
853    to: String,
854    mint: String,
855    amount: u64,
856    decimals: u8,
857    create_ata_if_needed: bool,
858}
859
860impl TokenTransferBuilder {
861    /// Create a new token transfer builder
862    pub fn new(
863        from: impl Into<String>,
864        to: impl Into<String>,
865        mint: impl Into<String>,
866        amount: u64,
867        decimals: u8,
868    ) -> Self {
869        Self {
870            from: from.into(),
871            to: to.into(),
872            mint: mint.into(),
873            amount,
874            decimals,
875            create_ata_if_needed: true,
876        }
877    }
878
879    /// Disable automatic ATA creation
880    pub fn without_ata_creation(mut self) -> Self {
881        self.create_ata_if_needed = false;
882        self
883    }
884
885    /// Build token transfer instructions
886    pub async fn build_instructions(&self, adapter: &SolanaAdapter) -> Result<Vec<Instruction>> {
887        let from_pubkey = Pubkey::from_str(&self.from)
888            .map_err(|e| Error::InvalidConfig(format!("Invalid from: {}", e)))?;
889        let to_pubkey = Pubkey::from_str(&self.to)
890            .map_err(|e| Error::InvalidConfig(format!("Invalid to: {}", e)))?;
891        let mint_pubkey = Pubkey::from_str(&self.mint)
892            .map_err(|e| Error::InvalidConfig(format!("Invalid mint: {}", e)))?;
893
894        let from_ata =
895            spl_associated_token_account::get_associated_token_address(&from_pubkey, &mint_pubkey);
896        let to_ata =
897            spl_associated_token_account::get_associated_token_address(&to_pubkey, &mint_pubkey);
898
899        let mut instructions = Vec::new();
900
901        // Create destination ATA if needed
902        if self.create_ata_if_needed
903            && let Some(create_ata_ix) = adapter
904                .build_create_ata_instruction(&self.from, &self.to, &self.mint)
905                .await?
906        {
907            instructions.push(create_ata_ix);
908        }
909
910        // Add transfer instruction
911        let transfer_ix = spl_token::instruction::transfer_checked(
912            &spl_token::id(),
913            &from_ata,
914            &mint_pubkey,
915            &to_ata,
916            &from_pubkey,
917            &[],
918            self.amount,
919            self.decimals,
920        )
921        .map_err(|e| Error::ChainError(format!("Failed to create transfer instruction: {}", e)))?;
922
923        instructions.push(transfer_ix);
924
925        Ok(instructions)
926    }
927}
928
929#[cfg(test)]
930mod tests {
931    use super::*;
932
933    #[test]
934    fn test_config_creation() {
935        let mainnet = SolanaConfig::mainnet();
936        assert_eq!(mainnet.chain_id, ChainId::SOLANA_MAINNET);
937
938        let devnet = SolanaConfig::devnet();
939        assert_eq!(devnet.chain_id, ChainId::SOLANA_DEVNET);
940    }
941
942    #[test]
943    fn test_lamports_conversion() {
944        assert_eq!(SolanaAdapter::lamports_to_sol(1_000_000_000), "1");
945        assert_eq!(SolanaAdapter::lamports_to_sol(500_000_000), "0.5");
946        assert_eq!(SolanaAdapter::lamports_to_sol(1_500_000_000), "1.5");
947        assert_eq!(SolanaAdapter::lamports_to_sol(0), "0");
948
949        assert_eq!(SolanaAdapter::sol_to_lamports("1").unwrap(), 1_000_000_000);
950        assert_eq!(SolanaAdapter::sol_to_lamports("0.5").unwrap(), 500_000_000);
951        assert_eq!(
952            SolanaAdapter::sol_to_lamports("1.5").unwrap(),
953            1_500_000_000
954        );
955    }
956
957    #[test]
958    fn test_address_validation() {
959        let config = SolanaConfig::mainnet();
960        let adapter = SolanaAdapter::new(config).unwrap();
961
962        // Valid base58 public key
963        assert!(adapter.is_valid_address("11111111111111111111111111111111"));
964        assert!(adapter.is_valid_address("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"));
965
966        // Invalid addresses
967        assert!(!adapter.is_valid_address("0x742d35Cc6634C0532925a3b844Bc9e7595f4e123"));
968        assert!(!adapter.is_valid_address("invalid"));
969    }
970
971    #[test]
972    fn test_derive_address() {
973        let config = SolanaConfig::mainnet();
974        let adapter = SolanaAdapter::new(config).unwrap();
975
976        // 32-byte key
977        let key32 = [1u8; 32];
978        let addr = adapter.derive_address(&key32).unwrap();
979        assert!(!addr.is_empty());
980
981        // 33-byte compressed secp256k1
982        let key33 = [2u8; 33];
983        let addr = adapter.derive_address(&key33).unwrap();
984        assert!(!addr.is_empty());
985    }
986
987    #[test]
988    fn test_commitment_as_str() {
989        assert_eq!(SolanaCommitment::Processed.as_str(), "processed");
990        assert_eq!(SolanaCommitment::Confirmed.as_str(), "confirmed");
991        assert_eq!(SolanaCommitment::Finalized.as_str(), "finalized");
992    }
993}