forc_node/
chain_config.rs

1use crate::{
2    consts::{
3        CHAIN_CONFIG_REPO_NAME, CONFIG_FOLDER, IGNITION_CONFIG_FOLDER_NAME,
4        LOCAL_CONFIG_FOLDER_NAME, TESTNET_CONFIG_FOLDER_NAME,
5    },
6    util::ask_user_yes_no_question,
7};
8use anyhow::{bail, Result};
9use forc_tracing::{println_action_green, println_warning};
10use forc_util::user_forc_directory;
11use serde::{Deserialize, Serialize};
12use sha1::{Digest, Sha1};
13use std::{
14    collections::{HashMap, HashSet},
15    fmt::Display,
16    fs,
17    path::PathBuf,
18};
19
20/// Different chain configuration options.
21#[derive(PartialEq, Eq, PartialOrd, Ord, Debug)]
22pub enum ChainConfig {
23    Local,
24    Testnet,
25    Ignition,
26}
27
28impl Display for ChainConfig {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            ChainConfig::Local => write!(f, "local"),
32            ChainConfig::Testnet => write!(f, "testnet"),
33            ChainConfig::Ignition => write!(f, "ignition"),
34        }
35    }
36}
37
38impl From<ChainConfig> for PathBuf {
39    fn from(value: ChainConfig) -> Self {
40        let folder_name = match value {
41            ChainConfig::Local => LOCAL_CONFIG_FOLDER_NAME,
42            ChainConfig::Testnet => TESTNET_CONFIG_FOLDER_NAME,
43            ChainConfig::Ignition => IGNITION_CONFIG_FOLDER_NAME,
44        };
45        user_forc_directory().join(CONFIG_FOLDER).join(folder_name)
46    }
47}
48
49/// A github api, content query response.
50/// Mainly used for fetching a download url and hash for configuration files.
51#[derive(Serialize, Deserialize, Debug)]
52struct GithubContentDetails {
53    name: String,
54    sha: String,
55    download_url: Option<String>,
56    #[serde(rename = "type")]
57    content_type: String,
58}
59
60/// `ConfigFetcher` is responsible for github api integration related to the
61/// configuration operations.
62/// Basically checks remote hash of the corresponding chain configuration.
63/// If there is a mismatch between local and remote instance, overrides the
64/// local instance with remote changes for testnet and mainnet configurations.
65///
66/// For local chain configuration, we only check for existence of it locally.
67/// If the local chain configuration is missing in user's local,
68/// `ConfigFetcher` fetches it but remote updates are not tracked for it.
69pub struct ConfigFetcher {
70    client: reqwest::Client,
71    base_url: String,
72    config_vault: PathBuf,
73}
74
75impl Default for ConfigFetcher {
76    /// Creates a new fetcher to interact with github.
77    /// By default user's chain configuration vault is at: `~/.forc/chainspecs`
78    fn default() -> Self {
79        Self {
80            client: reqwest::Client::new(),
81            base_url: "https://api.github.com".to_string(),
82            config_vault: user_forc_directory().join(CONFIG_FOLDER),
83        }
84    }
85}
86
87impl ConfigFetcher {
88    #[cfg(test)]
89    /// Override the base url, to be used in tests.
90    pub fn with_base_url(base_url: String) -> Self {
91        Self {
92            client: reqwest::Client::new(),
93            base_url,
94            config_vault: user_forc_directory().join(CONFIG_FOLDER),
95        }
96    }
97
98    #[cfg(test)]
99    pub fn with_test_config(base_url: String, config_vault: PathBuf) -> Self {
100        Self {
101            client: reqwest::Client::new(),
102            base_url,
103            config_vault,
104        }
105    }
106
107    fn get_base_url(&self) -> &str {
108        &self.base_url
109    }
110
111    fn build_api_endpoint(&self, folder_name: &str) -> String {
112        format!(
113            "{}/repos/FuelLabs/{}/contents/{}",
114            self.get_base_url(),
115            CHAIN_CONFIG_REPO_NAME,
116            folder_name,
117        )
118    }
119
120    /// Fetches contents from github to get hashes and download urls for
121    /// contents of the remote configuration repo at:
122    /// https://github.com/FuelLabs/chain-configuration/
123    async fn check_github_files(
124        &self,
125        conf: &ChainConfig,
126    ) -> anyhow::Result<Vec<GithubContentDetails>> {
127        let folder_name = match conf {
128            ChainConfig::Local => LOCAL_CONFIG_FOLDER_NAME,
129            ChainConfig::Testnet => TESTNET_CONFIG_FOLDER_NAME,
130            ChainConfig::Ignition => IGNITION_CONFIG_FOLDER_NAME,
131        };
132        let api_endpoint = self.build_api_endpoint(folder_name);
133
134        let response = self
135            .client
136            .get(&api_endpoint)
137            .header("User-Agent", "forc-node")
138            .send()
139            .await?;
140
141        if !response.status().is_success() {
142            bail!("failed to fetch updates from github")
143        }
144
145        let contents: Vec<GithubContentDetails> = response.json().await?;
146        Ok(contents)
147    }
148
149    /// Calculates the hash for the local configuration instance.
150    /// The hash calculation is based on github's hash calculation to match the
151    /// github api response.
152    fn check_local_files(&self, conf: &ChainConfig) -> Result<Option<HashMap<String, String>>> {
153        let folder_name = match conf {
154            ChainConfig::Local => bail!("Local configuration should not be checked"),
155            ChainConfig::Testnet => TESTNET_CONFIG_FOLDER_NAME,
156            ChainConfig::Ignition => IGNITION_CONFIG_FOLDER_NAME,
157        };
158
159        let folder_path = self.config_vault.join(folder_name);
160
161        if !folder_path.exists() {
162            return Ok(None);
163        }
164
165        let mut files = HashMap::new();
166        for entry in std::fs::read_dir(&folder_path)? {
167            let entry = entry?;
168            if entry.path().is_file() {
169                let content = std::fs::read(entry.path())?;
170                // Calculate SHA1 the same way github does
171                let mut hasher = Sha1::new();
172                hasher.update(b"blob ");
173                hasher.update(content.len().to_string().as_bytes());
174                hasher.update([0]);
175                hasher.update(&content);
176                let sha = format!("{:x}", hasher.finalize());
177
178                let name = entry.file_name().into_string().unwrap();
179                files.insert(name, sha);
180            }
181        }
182
183        Ok(Some(files))
184    }
185
186    /// Checks if a fetch is required by comparing the hashes of individual files
187    /// of the given chain config in the local instance to the one in github by
188    /// utilizing the github content abi.
189    pub async fn check_fetch_required(&self, conf: &ChainConfig) -> anyhow::Result<bool> {
190        if *conf == ChainConfig::Local {
191            return Ok(false);
192        }
193
194        let local_files = match self.check_local_files(conf)? {
195            Some(files) => files,
196            None => return Ok(true), // No local files, need to fetch
197        };
198
199        let github_files = self.check_github_files(conf).await?;
200
201        // Compare files
202        for github_file in &github_files {
203            if github_file.content_type == "file" {
204                match local_files.get(&github_file.name) {
205                    Some(local_sha) if local_sha == &github_file.sha => continue,
206                    _ => return Ok(true), // SHA mismatch or file doesn't exist locally
207                }
208            }
209        }
210
211        // Also check if we have any extra files locally that aren't on GitHub
212        let github_filenames: HashSet<_> = github_files
213            .iter()
214            .filter(|f| f.content_type == "file")
215            .map(|f| &f.name)
216            .collect();
217
218        let local_filenames: HashSet<_> = local_files.keys().collect();
219
220        if local_filenames != github_filenames {
221            return Ok(true);
222        }
223
224        Ok(false)
225    }
226
227    /// Download the chain config for given mode. Fetches the corresponding
228    /// directory from: https://github.com/FuelLabs/chain-configuration/.
229    pub async fn download_config(&self, conf: &ChainConfig) -> anyhow::Result<()> {
230        let folder_name = match conf {
231            ChainConfig::Local => LOCAL_CONFIG_FOLDER_NAME,
232            ChainConfig::Testnet => TESTNET_CONFIG_FOLDER_NAME,
233            ChainConfig::Ignition => IGNITION_CONFIG_FOLDER_NAME,
234        };
235
236        let api_endpoint = format!(
237            "https://api.github.com/repos/FuelLabs/{CHAIN_CONFIG_REPO_NAME}/contents/{folder_name}",
238        );
239
240        let contents = self.fetch_folder_contents(&api_endpoint).await?;
241
242        // Create config directory if it doesn't exist
243        let config_dir = user_forc_directory().join(CONFIG_FOLDER);
244        let target_dir = config_dir.join(folder_name);
245        fs::create_dir_all(&target_dir)?;
246
247        // Download each file
248        for item in contents {
249            if item.content_type == "file" {
250                if let Some(download_url) = item.download_url {
251                    let file_path = target_dir.join(&item.name);
252
253                    let response = self.client.get(&download_url).send().await?;
254
255                    if !response.status().is_success() {
256                        bail!("Failed to download file: {}", item.name);
257                    }
258
259                    let content = response.bytes().await?;
260                    fs::write(file_path, content)?;
261                }
262            }
263        }
264
265        Ok(())
266    }
267
268    /// Helper function to fetch folder contents from github.
269    async fn fetch_folder_contents(&self, url: &str) -> anyhow::Result<Vec<GithubContentDetails>> {
270        let response = self
271            .client
272            .get(url)
273            .header("User-Agent", "forc-node")
274            .send()
275            .await?;
276
277        if !response.status().is_success() {
278            bail!("failed to fetch contents from github");
279        }
280
281        Ok(response.json().await?)
282    }
283}
284
285/// Local configuration is validated based on its existence. Meaning that if
286/// the configuration exists in user's local it is validated. If it is missing
287/// the configuration files are fetched from remote.
288async fn validate_local_chainconfig(fetcher: &ConfigFetcher) -> anyhow::Result<()> {
289    let user_conf_dir = user_forc_directory().join(CONFIG_FOLDER);
290    let local_conf_dir = user_conf_dir.join(LOCAL_CONFIG_FOLDER_NAME);
291    if !local_conf_dir.exists() {
292        println_warning(&format!(
293            "Local node configuration files are missing at {}",
294            local_conf_dir.display()
295        ));
296        // Ask user if they want to update the chain config.
297        let update = ask_user_yes_no_question("Would you like to download network configuration?")?;
298        if update {
299            fetcher.download_config(&ChainConfig::Local).await?;
300        } else {
301            bail!(
302                "Missing local network configuration, create one at {}",
303                local_conf_dir.display()
304            );
305        }
306    }
307    Ok(())
308}
309
310/// Testnet and mainnet chain configurations are validated against the remote
311/// versions from github. If local files exists for these configurations, hash
312/// values are collected from remote, and compared to find out if there any
313/// changes introduced in remote. If there is the chain configuration is
314/// fetched again to ensure, the bootstrapped node can sync with the rest of
315/// the network without any issues related to a different chain configuration.
316async fn validate_remote_chainconfig(
317    fetcher: &ConfigFetcher,
318    conf: &ChainConfig,
319) -> anyhow::Result<()> {
320    // For testnet and mainnet configs, we need to check online.
321    println_action_green("Checking", "for network configuration updates.");
322
323    if fetcher.check_fetch_required(conf).await? {
324        println_warning(&format!(
325            "A network configuration update detected for {conf}, this might create problems while syncing with rest of the network"
326        ));
327        // Ask user if they want to update the chain config.
328        let update = ask_user_yes_no_question("Would you like to update network configuration?")?;
329        if update {
330            println_action_green("Updating", &format!("configuration files for {conf}",));
331            fetcher.download_config(conf).await?;
332            println_action_green(
333                "Finished",
334                &format!("updating configuration files for {conf}",),
335            );
336        }
337    } else {
338        println_action_green(&format!("{conf}"), "is up-to-date.");
339    }
340    Ok(())
341}
342
343/// Check local state of the configuration file in the vault (if they exists)
344/// and compare them to the remote one in github. If a change is detected asks
345/// user if they want to update, and does the update for them.
346pub async fn check_and_update_chain_config(conf: ChainConfig) -> anyhow::Result<()> {
347    let fetcher = ConfigFetcher::default();
348    match conf {
349        ChainConfig::Local => validate_local_chainconfig(&fetcher).await?,
350        remote_config => validate_remote_chainconfig(&fetcher, &remote_config).await?,
351    }
352    Ok(())
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use tempfile::TempDir;
359    use wiremock::{
360        matchers::{method, path},
361        Mock, MockServer, ResponseTemplate,
362    };
363
364    // Helper function to create dummy github response
365    fn create_github_response(files: &[(&str, &str)]) -> Vec<GithubContentDetails> {
366        files
367            .iter()
368            .map(|(name, content)| {
369                let mut hasher = Sha1::new();
370                hasher.update(b"blob ");
371                hasher.update(content.len().to_string().as_bytes());
372                hasher.update([0]);
373                hasher.update(content.as_bytes());
374                let sha = format!("{:x}", hasher.finalize());
375
376                GithubContentDetails {
377                    name: name.to_string(),
378                    sha,
379                    download_url: Some(format!("https://raw.githubusercontent.com/test/{name}")),
380                    content_type: "file".to_string(),
381                }
382            })
383            .collect()
384    }
385
386    #[tokio::test]
387    async fn test_fetch_not_required_when_files_match() {
388        let mock_server = MockServer::start().await;
389        let test_files = [
390            ("config.json", "test config content"),
391            ("metadata.json", "test metadata content"),
392        ];
393
394        // Create test directory and files
395        let test_dir = TempDir::new().unwrap();
396        let config_path = test_dir.path().to_path_buf();
397        let test_folder = config_path.join(TESTNET_CONFIG_FOLDER_NAME);
398        fs::create_dir_all(&test_folder).unwrap();
399
400        for (name, content) in &test_files {
401            fs::write(test_folder.join(name), content).unwrap();
402        }
403
404        // Setup mock response
405        let github_response = create_github_response(&test_files);
406        Mock::given(method("GET"))
407            .and(path(format!(
408                "/repos/FuelLabs/{CHAIN_CONFIG_REPO_NAME}/contents/{TESTNET_CONFIG_FOLDER_NAME}"
409            )))
410            .respond_with(ResponseTemplate::new(200).set_body_json(&github_response))
411            .mount(&mock_server)
412            .await;
413
414        let fetcher = ConfigFetcher::with_test_config(mock_server.uri(), config_path);
415
416        let needs_fetch = fetcher
417            .check_fetch_required(&ChainConfig::Testnet)
418            .await
419            .unwrap();
420
421        assert!(
422            !needs_fetch,
423            "Fetch should not be required when files match"
424        );
425    }
426
427    #[tokio::test]
428    async fn test_fetch_required_when_files_differ() {
429        let mock_server = MockServer::start().await;
430
431        // Create local test files
432        let test_dir = TempDir::new().unwrap();
433        let config_path = test_dir.path().join("fuel").join("configs");
434        let test_folder = config_path.join(TESTNET_CONFIG_FOLDER_NAME);
435        fs::create_dir_all(&test_folder).unwrap();
436
437        let local_files = [
438            ("config.json", "old config content"),
439            ("metadata.json", "old metadata content"),
440        ];
441
442        for (name, content) in &local_files {
443            fs::write(test_folder.join(name), content).unwrap();
444        }
445
446        // Setup mock GitHub response with different content
447        let github_files = [
448            ("config.json", "new config content"),
449            ("metadata.json", "new metadata content"),
450        ];
451        let github_response = create_github_response(&github_files);
452
453        Mock::given(method("GET"))
454            .and(path(format!(
455                "/repos/FuelLabs/{CHAIN_CONFIG_REPO_NAME}/contents/{TESTNET_CONFIG_FOLDER_NAME}"
456            )))
457            .respond_with(ResponseTemplate::new(200).set_body_json(&github_response))
458            .mount(&mock_server)
459            .await;
460
461        let fetcher = ConfigFetcher::with_base_url(mock_server.uri());
462
463        let needs_fetch = fetcher
464            .check_fetch_required(&ChainConfig::Testnet)
465            .await
466            .unwrap();
467
468        assert!(needs_fetch, "Fetch should be required when files differ");
469    }
470
471    #[tokio::test]
472    async fn test_fetch_required_when_files_missing() {
473        let mock_server = MockServer::start().await;
474
475        // Create local test files (missing one file)
476        let test_dir = TempDir::new().unwrap();
477        let config_path = test_dir.path().join("fuel").join("configs");
478        let test_folder = config_path.join(TESTNET_CONFIG_FOLDER_NAME);
479        fs::create_dir_all(&test_folder).unwrap();
480
481        let local_files = [("config.json", "test config content")];
482
483        for (name, content) in &local_files {
484            fs::write(test_folder.join(name), content).unwrap();
485        }
486
487        // Setup mock GitHub response with extra file
488        let github_files = [
489            ("config.json", "test config content"),
490            ("metadata.json", "test metadata content"),
491        ];
492        let github_response = create_github_response(&github_files);
493
494        Mock::given(method("GET"))
495            .and(path(format!(
496                "/repos/FuelLabs/{CHAIN_CONFIG_REPO_NAME}/contents/{TESTNET_CONFIG_FOLDER_NAME}"
497            )))
498            .respond_with(ResponseTemplate::new(200).set_body_json(&github_response))
499            .mount(&mock_server)
500            .await;
501
502        let fetcher = ConfigFetcher::with_base_url(mock_server.uri());
503
504        let needs_fetch = fetcher
505            .check_fetch_required(&ChainConfig::Testnet)
506            .await
507            .unwrap();
508
509        assert!(
510            needs_fetch,
511            "Fetch should be required when files are missing"
512        );
513    }
514
515    #[tokio::test]
516    async fn test_local_configuration_never_needs_fetch() {
517        let fetcher = ConfigFetcher::default();
518        let needs_fetch = fetcher
519            .check_fetch_required(&ChainConfig::Local)
520            .await
521            .unwrap();
522
523        assert!(!needs_fetch, "Local configuration should never need fetch");
524    }
525
526    #[tokio::test]
527    async fn test_fetch_required_when_extra_local_files() {
528        let mock_server = MockServer::start().await;
529
530        // Create local test files (with extra file)
531        let test_dir = TempDir::new().unwrap();
532        let config_path = test_dir.path().join("fuel").join("configs");
533        let test_folder = config_path.join(TESTNET_CONFIG_FOLDER_NAME);
534        fs::create_dir_all(&test_folder).unwrap();
535
536        let local_files = [
537            ("config.json", "test config content"),
538            ("metadata.json", "test metadata content"),
539            ("extra.json", "extra file content"),
540        ];
541
542        for (name, content) in &local_files {
543            fs::write(test_folder.join(name), content).unwrap();
544        }
545
546        // Setup mock GitHub response with fewer files
547        let github_files = [
548            ("config.json", "test config content"),
549            ("metadata.json", "test metadata content"),
550        ];
551        let github_response = create_github_response(&github_files);
552
553        Mock::given(method("GET"))
554            .and(path(format!(
555                "/repos/FuelLabs/{CHAIN_CONFIG_REPO_NAME}/contents/{TESTNET_CONFIG_FOLDER_NAME}"
556            )))
557            .respond_with(ResponseTemplate::new(200).set_body_json(&github_response))
558            .mount(&mock_server)
559            .await;
560
561        let fetcher = ConfigFetcher::with_base_url(mock_server.uri());
562
563        let needs_fetch = fetcher
564            .check_fetch_required(&ChainConfig::Testnet)
565            .await
566            .unwrap();
567
568        assert!(
569            needs_fetch,
570            "Fetch should be required when there are extra local files"
571        );
572    }
573}