Skip to main content

soon_migrate/
migration.rs

1//! Migration module for handling the migration of Solana Anchor projects to the SOON Network.
2//!
3//! This module provides functionality to migrate Anchor.toml configuration files,
4//! detect oracles, and handle backup/restore operations.
5
6use crate::cli::Config;
7use crate::errors::MigrationError;
8use crate::oracle::{OracleDetector, OracleReport};
9use colored::*;
10use serde::{Deserialize, Serialize};
11use std::fs;
12use std::path::Path;
13
14/// Represents the provider configuration in Anchor.toml
15#[derive(Deserialize, Serialize, Debug)]
16struct Provider {
17    /// The cluster URL or name (e.g., "mainnet-beta", "testnet", "devnet")
18    cluster: String,
19    /// The wallet path or configuration
20    wallet: String,
21}
22
23/// Represents the programs section in Anchor.toml
24#[derive(Deserialize, Serialize, Debug)]
25struct Programs {
26    /// Program IDs for the localnet environment
27    #[serde(rename = "localnet")]
28    localnet: std::collections::HashMap<String, String>,
29}
30
31/// Represents the structure of an Anchor.toml file
32#[derive(Deserialize, Serialize, Debug)]
33struct AnchorToml {
34    /// Provider configuration
35    provider: Provider,
36    /// Program configurations
37    programs: Programs,
38    /// Any additional fields that aren't explicitly defined
39    #[serde(flatten)]
40    extra: std::collections::HashMap<String, toml::Value>,
41}
42
43/// The result of a migration operation
44#[derive(Debug)]
45pub struct MigrationResult {
46    /// Whether the configuration was updated
47    pub config_updated: bool,
48    /// Optional report about detected oracles
49    pub oracle_report: Option<OracleReport>,
50    /// Any warnings that occurred during migration
51    pub warnings: Vec<String>,
52    /// Recommended next steps for the user
53    pub next_steps: Vec<String>,
54}
55
56fn map_cluster_to_soon(cluster: &str) -> &'static str {
57    match cluster.to_lowercase().as_str() {
58        "mainnet-beta" | "mainnet" => "https://rpc.mainnet.soo.network/rpc",
59        "testnet" => "https://rpc.testnet.soo.network/rpc", 
60        "devnet" | _ => "https://rpc.devnet.soo.network/rpc",
61    }
62}
63
64/// Run the migration process for an Anchor project
65///
66/// # Arguments
67/// * `config` - Configuration for the migration
68///
69/// # Returns
70/// A `Result` containing the migration result or an error
71///
72/// # Errors
73/// Returns `MigrationError` if any step of the migration fails
74pub fn run_migration(config: &Config) -> Result<MigrationResult, MigrationError> {
75    validate_anchor_project(&config.path)?;
76
77    let mut result = MigrationResult {
78        config_updated: false,
79        oracle_report: None,
80        warnings: Vec::new(),
81        next_steps: Vec::new(),
82    };
83
84    // Always run oracle detection first
85    if config.verbose {
86        println!("{}", "Running oracle detection...".cyan());
87    }
88    
89    let oracle_report = OracleDetector::scan_project(&config.path, config.verbose)?;
90    result.oracle_report = Some(oracle_report);
91
92    // If oracle-only mode, skip Anchor.toml migration
93    if config.oracle_only {
94        if config.verbose {
95            println!("{}", "Oracle-only mode: skipping Anchor.toml migration".yellow());
96        }
97        return Ok(result);
98    }
99
100    // Proceed with Anchor.toml migration
101    let anchor_toml_path = Path::new(&config.path).join("Anchor.toml");
102
103    // Backup original Anchor.toml
104    let backup_path = anchor_toml_path.with_extension("toml.bak");
105    fs::copy(&anchor_toml_path, &backup_path)
106        .map_err(|e| MigrationError::BackupFailed(e.to_string()))?;
107
108    if config.verbose {
109        println!("{}", "Backup created successfully.".cyan());
110    }
111
112    // Read Anchor.toml
113    let content = fs::read_to_string(&anchor_toml_path)
114        .map_err(|e| MigrationError::ReadFailed(e.to_string()))?;
115
116    // Parse TOML
117    let mut toml_value: toml::Value = content
118        .parse()
119        .map_err(|e: toml::de::Error| MigrationError::TomlParseError(e.to_string()))?;
120
121    let mut config_changed = false;
122
123    // Update the cluster value in the provider section
124    if let Some(provider) = toml_value.get_mut("provider") {
125        if let Some(table) = provider.as_table_mut() {
126            // Store cluster value first before modifying table
127            let cluster_value = table.get("cluster")
128                .and_then(|c| c.as_str())
129                .map(|c| c.to_string());
130            
131            if let Some(cluster) = cluster_value {
132                let soon_rpc = map_cluster_to_soon(&cluster);
133                table.insert("cluster".to_string(), toml::Value::String(soon_rpc.to_string()));
134                
135                if config.verbose {
136                    println!("{}", format!("Updating cluster from '{}' to '{}'", cluster, soon_rpc).cyan());
137                }
138                config_changed = true;
139            }
140        }
141    }
142
143    // Determine the appropriate network name before modifying programs section
144    let network_name = {
145        if let Some(provider) = toml_value.get("provider") {
146            if let Some(cluster) = provider.get("cluster").and_then(|c| c.as_str()) {
147                if cluster.contains("mainnet") {
148                    "mainnet"
149                } else if cluster.contains("testnet") {
150                    "testnet"
151                } else {
152                    "devnet"
153                }
154            } else {
155                "devnet"
156            }
157        } else {
158            "devnet"
159        }
160    };
161
162    // Update programs section: change programs.localnet to appropriate network
163    if let Some(programs) = toml_value.get_mut("programs") {
164        if let Some(table) = programs.as_table_mut() {
165            if let Some(localnet) = table.remove("localnet") {
166                table.insert(network_name.to_string(), localnet);
167                if config.verbose {
168                    println!("{}", format!("Updated programs.localnet to programs.{}", network_name).cyan());
169                }
170                config_changed = true;
171            }
172        }
173    }
174
175    // Add oracle-related warnings if oracles detected
176    if let Some(ref oracle_report) = result.oracle_report {
177        if !oracle_report.detected_oracles.is_empty() {
178            result.warnings.push("Oracle usage detected in your project. Review the oracle migration recommendations.".to_string());
179            
180            // Add specific warnings for high-confidence detections
181            for detection in &oracle_report.detected_oracles {
182                if matches!(detection.confidence, crate::oracle::ConfidenceLevel::High) {
183                    result.warnings.push(format!("{:?} oracle detected - migration required for SOON compatibility", detection.oracle_type));
184                }
185            }
186        }
187    }
188
189    // Generate next steps
190    result.next_steps.push("1. Update your dependencies if using oracles".to_string());
191    result.next_steps.push("2. Test your project on SOON devnet".to_string());
192    result.next_steps.push("3. Review oracle integration if detected".to_string());
193    result.next_steps.push("4. Deploy to SOON Network".to_string());
194
195    if config.verbose {
196        println!("{}", "Configuration updated successfully.".cyan());
197    }
198
199    // Write back to Anchor.toml unless dry_run
200    if !config.dry_run && config_changed {
201        let toml_string = toml::to_string_pretty(&toml_value)
202            .map_err(|e| MigrationError::TomlParseError(e.to_string()))?;
203
204        fs::write(&anchor_toml_path, toml_string)
205            .map_err(|e| MigrationError::WriteFailed(e.to_string()))?;
206
207        if config.verbose {
208            println!("{}", "Anchor.toml written successfully.".cyan());
209        }
210        result.config_updated = true;
211    } else if config.dry_run {
212        if config.verbose {
213            println!("{}", "Dry run enabled. Changes not written.".yellow());
214            println!(
215                "{}",
216                toml::to_string_pretty(&toml_value)
217                    .map_err(|e| MigrationError::TomlParseError(e.to_string()))?
218                    .cyan()
219            );
220        }
221    } else if !config_changed {
222        if config.verbose {
223            println!("{}", "No changes needed to Anchor.toml".green());
224        }
225    }
226
227    Ok(result)
228}
229
230/// Scan the project for oracles without performing any migrations
231///
232/// # Arguments
233/// * `config` - Configuration for the oracle scan
234///
235/// # Returns
236/// A `Result` containing the oracle report or an error
237///
238/// # Errors
239/// Returns `MigrationError` if the oracle detection fails
240pub fn run_oracle_scan_only(config: &Config) -> Result<OracleReport, MigrationError> {
241    validate_anchor_project(&config.path)?;
242    OracleDetector::scan_project(&config.path, config.verbose)
243}
244
245/// Restore the Anchor.toml file from a backup
246///
247/// # Arguments
248/// * `path` - Path to the backup file
249///
250/// # Returns
251/// A `Result` indicating success or an error
252///
253/// # Errors
254/// Returns `MigrationError` if the backup cannot be restored
255pub fn restore_backup(path: &str) -> Result<(), MigrationError> {
256    let anchor_toml_path = Path::new(path).join("Anchor.toml");
257    let backup_path = anchor_toml_path.with_extension("toml.bak");
258
259    if !backup_path.exists() {
260        return Err(MigrationError::BackupNotFound(
261            backup_path.to_string_lossy().into_owned(),
262        ));
263    }
264
265    fs::copy(&backup_path, &anchor_toml_path)
266        .map_err(|e| MigrationError::RestoreFailed(e.to_string()))?;
267
268    if Path::new(&backup_path).exists() {
269        fs::remove_file(backup_path)
270            .map_err(|e| MigrationError::RestoreFailed(e.to_string()))?;
271    }
272
273    Ok(())
274}
275
276/// Validate that the given path contains a valid Anchor project
277///
278/// # Arguments
279/// * `path` - Path to validate as an Anchor project
280///
281/// # Returns
282/// A `Result` indicating if the path is valid or an error
283///
284/// # Errors
285/// Returns `MigrationError::NotAnAnchorProject` if validation fails
286fn validate_anchor_project(path: &str) -> Result<(), MigrationError> {
287    let anchor_toml_path = Path::new(path).join("Anchor.toml");
288    if !anchor_toml_path.exists() {
289        return Err(MigrationError::NotAnAnchorProject(path.to_string()));
290    }
291
292    let cargo_toml_path = Path::new(path).join("Cargo.toml");
293    if !cargo_toml_path.exists() {
294        return Err(MigrationError::NotAnAnchorProject(path.to_string()));
295    }
296
297    Ok(())
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use std::fs;
304    use tempfile::TempDir;
305
306    fn create_test_anchor_project() -> TempDir {
307        let temp_dir = TempDir::new().unwrap();
308        let anchor_toml_content = r#"
309[toolchain]
310
311[features]
312resolution = true
313skip-lint = false
314
315[programs.localnet]
316migration = "EtQdsPNDckBhME3gRjcj9Z4Z9tGEYAoHjWKv7aHJgBua"
317
318[registry]
319url = "https://api.apr.dev"
320
321[provider]
322cluster = "devnet"
323wallet = "~/.config/solana/id.json"
324
325[scripts]
326test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
327"#;
328
329        let cargo_toml_content = r#"
330[package]
331name = "test"
332version = "0.1.0"
333
334[dependencies]
335anchor-lang = "0.28.0"
336"#;
337
338        fs::write(temp_dir.path().join("Anchor.toml"), anchor_toml_content).unwrap();
339        fs::write(temp_dir.path().join("Cargo.toml"), cargo_toml_content).unwrap();
340
341        temp_dir
342    }
343
344    fn create_test_anchor_project_with_oracle() -> TempDir {
345        let temp_dir = TempDir::new().unwrap();
346        let anchor_toml_content = r#"
347[toolchain]
348
349[features]
350resolution = true
351skip-lint = false
352
353[programs.localnet]
354migration = "EtQdsPNDckBhME3gRjcj9Z4Z9tGEYAoHjWKv7aHJgBua"
355
356[registry]
357url = "https://api.apr.dev"
358
359[provider]
360cluster = "devnet"
361wallet = "~/.config/solana/id.json"
362
363[scripts]
364test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
365"#;
366
367        let cargo_toml_content = r#"
368[package]
369name = "test"
370version = "0.1.0"
371
372[dependencies]
373anchor-lang = "0.28.0"
374pyth-solana-receiver-sdk = "0.2.0"
375"#;
376
377        let rust_code = r#"
378use anchor_lang::prelude::*;
379use pyth_solana_receiver_sdk::PriceUpdateV2;
380
381pub fn get_price() -> Result<()> {
382    // Get price from Pyth
383    let price = price_update.get_price_no_older_than(&clock, 60)?;
384    Ok(())
385}
386"#;
387
388        fs::write(temp_dir.path().join("Anchor.toml"), anchor_toml_content).unwrap();
389        fs::write(temp_dir.path().join("Cargo.toml"), cargo_toml_content).unwrap();
390        
391        // Create src directory and price.rs file
392        fs::create_dir_all(temp_dir.path().join("src")).unwrap();
393        fs::write(temp_dir.path().join("src").join("price.rs"), rust_code).unwrap();
394
395        temp_dir
396    }
397
398    #[test]
399    fn test_basic_migration() {
400        let test_dir = create_test_anchor_project();
401        let config = Config {
402            path: test_dir.path().to_str().unwrap().to_string(),
403            dry_run: false,
404            verbose: false,
405            restore: false,
406            show_guide: false,
407            oracle_only: false,
408        };
409
410        let result = run_migration(&config).unwrap();
411        
412        // Verify config was updated
413        assert!(result.config_updated);
414        
415        // Verify file was changed
416        let content = fs::read_to_string(test_dir.path().join("Anchor.toml")).unwrap();
417        assert!(content.contains("https://rpc.devnet.soo.network/rpc"));
418        assert!(content.contains("[programs.devnet]"));
419    }
420
421    #[test]
422    fn test_migration_with_oracle_detection() {
423        let test_dir = create_test_anchor_project_with_oracle();
424        let config = Config {
425            path: test_dir.path().to_str().unwrap().to_string(),
426            dry_run: false,
427            verbose: false,
428            restore: false,
429            show_guide: false,
430            oracle_only: false,
431        };
432
433        let result = run_migration(&config).unwrap();
434        
435        // Verify oracle was detected
436        assert!(result.oracle_report.is_some());
437        let oracle_report = result.oracle_report.unwrap();
438        assert!(!oracle_report.detected_oracles.is_empty());
439        
440        // Verify Pyth was detected
441        let has_pyth = oracle_report.detected_oracles.iter()
442            .any(|d| matches!(d.oracle_type, crate::oracle::OracleType::Pyth));
443        assert!(has_pyth);
444
445        // Verify config was updated
446        assert!(result.config_updated);
447        
448        // Verify warnings about oracle usage
449        assert!(!result.warnings.is_empty());
450    }
451
452    #[test]
453    fn test_oracle_only_mode() {
454        let test_dir = create_test_anchor_project_with_oracle();
455        let config = Config {
456            path: test_dir.path().to_str().unwrap().to_string(),
457            dry_run: false,
458            verbose: false,
459            restore: false,
460            show_guide: false,
461            oracle_only: true,
462        };
463
464        let result = run_migration(&config).unwrap();
465        
466        // Verify oracle was detected
467        assert!(result.oracle_report.is_some());
468        
469        // Verify config was NOT updated in oracle-only mode
470        assert!(!result.config_updated);
471        
472        // Verify original file wasn't changed
473        let content = fs::read_to_string(test_dir.path().join("Anchor.toml")).unwrap();
474        assert!(content.contains("cluster = \"devnet\""));
475    }
476
477    #[test]
478    fn test_dry_run_mode() {
479        let test_dir = create_test_anchor_project();
480        let config = Config {
481            path: test_dir.path().to_str().unwrap().to_string(),
482            dry_run: true,
483            verbose: false,
484            restore: false,
485            show_guide: false,
486            oracle_only: false,
487        };
488
489        let result = run_migration(&config).unwrap();
490        
491        // Verify config was NOT updated in dry-run mode
492        assert!(!result.config_updated);
493        
494        // Verify original file wasn't changed
495        let content = fs::read_to_string(test_dir.path().join("Anchor.toml")).unwrap();
496        assert!(content.contains("cluster = \"devnet\""));
497    }
498
499    #[test]
500    fn test_network_mapping() {
501        assert_eq!(map_cluster_to_soon("mainnet-beta"), "https://rpc.mainnet.soo.network/rpc");
502        assert_eq!(map_cluster_to_soon("testnet"), "https://rpc.testnet.soo.network/rpc");
503        assert_eq!(map_cluster_to_soon("devnet"), "https://rpc.devnet.soo.network/rpc");
504        // Default fallback to devnet for unknown clusters
505        assert_eq!(map_cluster_to_soon("unknown"), "https://rpc.devnet.soo.network/rpc");
506    }
507
508    #[test]
509    fn test_restore_backup() {
510        let test_dir = create_test_anchor_project();
511
512        // First run migration
513        let config = Config {
514            path: test_dir.path().to_str().unwrap().to_string(),
515            dry_run: false,
516            verbose: false,
517            restore: false,
518            show_guide: false,
519            oracle_only: false,
520        };
521        run_migration(&config).unwrap();
522
523        // Then restore
524        let restore_result = restore_backup(test_dir.path().to_str().unwrap());
525        assert!(restore_result.is_ok());
526
527        // Verify content was restored
528        let content = fs::read_to_string(test_dir.path().join("Anchor.toml")).unwrap();
529        assert!(content.contains("cluster = \"devnet\""));
530    }
531
532    #[test]
533    fn test_invalid_path() {
534        let config = Config {
535            path: "/nonexistent/path".to_string(),
536            dry_run: false,
537            verbose: false,
538            restore: false,
539            show_guide: false,
540            oracle_only: false,
541        };
542
543        let result = run_migration(&config);
544        assert!(matches!(result, Err(MigrationError::NotAnAnchorProject(_))));
545    }
546}