iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

config_store.rs (19420B)


      1 #[cfg(unix)]
      2 use std::os::unix::fs::OpenOptionsExt;
      3 use std::{
      4     collections::BTreeMap,
      5     fs::{self, File, OpenOptions},
      6     io::Write,
      7     path::{Path, PathBuf},
      8     time::{SystemTime, UNIX_EPOCH},
      9 };
     10 
     11 use anyhow::{Context, Result, bail};
     12 use serde::{Deserialize, Serialize};
     13 
     14 use crate::domain::{Amount, MICRO_IUNA};
     15 
     16 const CONFIG_FILE_VERSION: u32 = 1;
     17 const AMOUNT_UNIT_MICROIUNA: &str = "microiuna";
     18 const LEGACY_AMOUNT_UNIT_PRE_RENAME: &str = concat!("micro", "l", "uun");
     19 pub const DEFAULT_BURN_AMOUNT: Amount = MICRO_IUNA / 10_000;
     20 pub const DEFAULT_BURN_FEE: Amount = 1;
     21 pub const DEFAULT_RECOVERY_VDF_TOP_RANK_PERCENT: u8 = 50;
     22 pub const DEFAULT_POW_MINING_WORKERS: u8 = 1;
     23 pub const MAX_POW_MINING_WORKERS: u8 = 32;
     24 pub const DEFAULT_P2P_BIND_PORT: u16 = 9444;
     25 pub const DEFAULT_STRATUM_BIND_PORT: u16 = 3333;
     26 pub const DEFAULT_WALLET_ENDPOINT_BIND_PORT: u16 = 18662;
     27 
     28 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
     29 pub struct UiConfig {
     30     pub setup_complete: bool,
     31     #[serde(skip_serializing, default)]
     32     pub auth_password_hash: Option<String>,
     33     pub mining_enabled: bool,
     34     pub pow_mining_enabled: bool,
     35     pub pow_mining_workers: u8,
     36     pub burn_per_block: Amount,
     37     pub burn_fee: Amount,
     38     pub recovery_vdf_top_rank_percent: u8,
     39     pub keep_track_of_metrics: bool,
     40     pub p2p_accept_inbound: bool,
     41     pub p2p_bind_port: u16,
     42     pub p2p_announce_addr: Option<String>,
     43     pub stratum_enabled: bool,
     44     pub stratum_bind_port: u16,
     45     pub wallet_endpoint_enabled: bool,
     46     pub wallet_endpoint_bind_port: u16,
     47     pub peers: Vec<String>,
     48     pub address_book: BTreeMap<String, String>,
     49 }
     50 
     51 impl Default for UiConfig {
     52     fn default() -> Self {
     53         Self {
     54             setup_complete: false,
     55             auth_password_hash: None,
     56             mining_enabled: false,
     57             pow_mining_enabled: false,
     58             pow_mining_workers: DEFAULT_POW_MINING_WORKERS,
     59             burn_per_block: DEFAULT_BURN_AMOUNT,
     60             burn_fee: DEFAULT_BURN_FEE,
     61             recovery_vdf_top_rank_percent: DEFAULT_RECOVERY_VDF_TOP_RANK_PERCENT,
     62             keep_track_of_metrics: false,
     63             p2p_accept_inbound: false,
     64             p2p_bind_port: DEFAULT_P2P_BIND_PORT,
     65             p2p_announce_addr: None,
     66             stratum_enabled: false,
     67             stratum_bind_port: DEFAULT_STRATUM_BIND_PORT,
     68             wallet_endpoint_enabled: false,
     69             wallet_endpoint_bind_port: DEFAULT_WALLET_ENDPOINT_BIND_PORT,
     70             peers: Vec::new(),
     71             address_book: BTreeMap::new(),
     72         }
     73     }
     74 }
     75 
     76 #[derive(Debug, Deserialize, Serialize)]
     77 struct ConfigFile {
     78     version: u32,
     79     #[serde(default)]
     80     amount_unit: Option<String>,
     81     setup_complete: bool,
     82     #[serde(default)]
     83     auth_password_hash: Option<String>,
     84     #[serde(default)]
     85     mining_enabled: Option<bool>,
     86     #[serde(default)]
     87     pow_mining_enabled: bool,
     88     #[serde(default = "default_pow_mining_workers")]
     89     pow_mining_workers: u8,
     90     #[serde(default)]
     91     burn_per_block: Amount,
     92     #[serde(default)]
     93     burn_fee: Option<Amount>,
     94     #[serde(default)]
     95     recovery_vdf_top_rank_percent: Option<u8>,
     96     #[serde(default)]
     97     keep_track_of_metrics: bool,
     98     #[serde(default)]
     99     p2p_accept_inbound: Option<bool>,
    100     #[serde(default = "default_p2p_bind_port")]
    101     p2p_bind_port: u16,
    102     #[serde(default)]
    103     p2p_announce_addr: Option<String>,
    104     #[serde(default)]
    105     stratum_enabled: bool,
    106     #[serde(default = "default_stratum_bind_port")]
    107     stratum_bind_port: u16,
    108     #[serde(default)]
    109     wallet_endpoint_enabled: bool,
    110     #[serde(default = "default_wallet_endpoint_bind_port")]
    111     wallet_endpoint_bind_port: u16,
    112     #[serde(default)]
    113     peers: Vec<String>,
    114     #[serde(default)]
    115     address_book: BTreeMap<String, String>,
    116 }
    117 
    118 pub fn load_or_create(path: &Path) -> Result<UiConfig> {
    119     if path.exists() {
    120         return load(path);
    121     }
    122 
    123     let config = UiConfig::default();
    124     save(path, &config)?;
    125     Ok(config)
    126 }
    127 
    128 pub fn save(path: &Path, config: &UiConfig) -> Result<()> {
    129     if let Some(parent) = path.parent() {
    130         fs::create_dir_all(parent)
    131             .with_context(|| format!("failed to create config directory {}", parent.display()))?;
    132     }
    133 
    134     let stored = ConfigFile {
    135         version: CONFIG_FILE_VERSION,
    136         amount_unit: Some(AMOUNT_UNIT_MICROIUNA.to_string()),
    137         setup_complete: config.setup_complete,
    138         auth_password_hash: config.auth_password_hash.clone(),
    139         mining_enabled: Some(config.mining_enabled),
    140         pow_mining_enabled: config.pow_mining_enabled,
    141         pow_mining_workers: clamp_pow_mining_workers(config.pow_mining_workers),
    142         burn_per_block: config.burn_per_block,
    143         burn_fee: Some(config.burn_fee),
    144         recovery_vdf_top_rank_percent: Some(config.recovery_vdf_top_rank_percent),
    145         keep_track_of_metrics: config.keep_track_of_metrics,
    146         p2p_accept_inbound: Some(config.p2p_accept_inbound),
    147         p2p_bind_port: config.p2p_bind_port,
    148         p2p_announce_addr: config.p2p_announce_addr.clone(),
    149         stratum_enabled: config.stratum_enabled,
    150         stratum_bind_port: config.stratum_bind_port,
    151         wallet_endpoint_enabled: config.wallet_endpoint_enabled,
    152         wallet_endpoint_bind_port: config.wallet_endpoint_bind_port,
    153         peers: config.peers.clone(),
    154         address_book: config.address_book.clone(),
    155     };
    156     let mut bytes =
    157         serde_json::to_vec_pretty(&stored).context("failed to serialize config file")?;
    158     bytes.push(b'\n');
    159     atomic_write_config_file(path, &bytes)
    160 }
    161 
    162 fn load(path: &Path) -> Result<UiConfig> {
    163     let bytes =
    164         fs::read(path).with_context(|| format!("failed to read config file {}", path.display()))?;
    165     parse_config_bytes(&bytes, &path.display().to_string())
    166 }
    167 
    168 fn parse_config_bytes(bytes: &[u8], source: &str) -> Result<UiConfig> {
    169     let stored: ConfigFile = serde_json::from_slice(bytes)
    170         .with_context(|| format!("failed to parse config file {source}"))?;
    171 
    172     if stored.version != CONFIG_FILE_VERSION {
    173         bail!(
    174             "unsupported config file version {} in {source}",
    175             stored.version
    176         );
    177     }
    178 
    179     let scale = if matches!(
    180         stored.amount_unit.as_deref(),
    181         Some(AMOUNT_UNIT_MICROIUNA) | Some(LEGACY_AMOUNT_UNIT_PRE_RENAME)
    182     ) {
    183         1
    184     } else {
    185         MICRO_IUNA
    186     };
    187 
    188     let p2p_accept_inbound = stored
    189         .p2p_accept_inbound
    190         .unwrap_or_else(|| stored.p2p_announce_addr.is_some());
    191 
    192     Ok(UiConfig {
    193         setup_complete: stored.setup_complete,
    194         auth_password_hash: stored.auth_password_hash,
    195         mining_enabled: stored.mining_enabled.unwrap_or(stored.burn_per_block > 0),
    196         pow_mining_enabled: stored.pow_mining_enabled,
    197         pow_mining_workers: clamp_pow_mining_workers(stored.pow_mining_workers),
    198         burn_per_block: stored.burn_per_block.saturating_mul(scale),
    199         burn_fee: stored
    200             .burn_fee
    201             .map(|fee| fee.saturating_mul(scale))
    202             .unwrap_or(DEFAULT_BURN_FEE),
    203         recovery_vdf_top_rank_percent: stored
    204             .recovery_vdf_top_rank_percent
    205             .unwrap_or(DEFAULT_RECOVERY_VDF_TOP_RANK_PERCENT)
    206             .min(100),
    207         keep_track_of_metrics: stored.keep_track_of_metrics,
    208         p2p_accept_inbound,
    209         p2p_bind_port: stored.p2p_bind_port,
    210         p2p_announce_addr: stored.p2p_announce_addr,
    211         stratum_enabled: stored.stratum_enabled,
    212         stratum_bind_port: stored.stratum_bind_port,
    213         wallet_endpoint_enabled: stored.wallet_endpoint_enabled,
    214         wallet_endpoint_bind_port: stored.wallet_endpoint_bind_port,
    215         peers: stored.peers,
    216         address_book: stored.address_book,
    217     })
    218 }
    219 
    220 #[cfg(feature = "fuzzing")]
    221 pub fn fuzz_parse_config(bytes: &[u8]) -> Result<UiConfig> {
    222     parse_config_bytes(bytes, "<fuzz>")
    223 }
    224 
    225 pub fn clamp_pow_mining_workers(workers: u8) -> u8 {
    226     workers.clamp(1, MAX_POW_MINING_WORKERS)
    227 }
    228 
    229 fn default_pow_mining_workers() -> u8 {
    230     DEFAULT_POW_MINING_WORKERS
    231 }
    232 
    233 fn default_p2p_bind_port() -> u16 {
    234     DEFAULT_P2P_BIND_PORT
    235 }
    236 
    237 fn default_stratum_bind_port() -> u16 {
    238     DEFAULT_STRATUM_BIND_PORT
    239 }
    240 
    241 fn default_wallet_endpoint_bind_port() -> u16 {
    242     DEFAULT_WALLET_ENDPOINT_BIND_PORT
    243 }
    244 
    245 fn atomic_write_config_file(path: &Path, bytes: &[u8]) -> Result<()> {
    246     if let Some(parent) = path.parent() {
    247         fs::create_dir_all(parent)
    248             .with_context(|| format!("failed to create config directory {}", parent.display()))?;
    249     }
    250 
    251     for attempt in 0..16 {
    252         let temp_path = temp_file_path(path, attempt);
    253         match create_config_file(&temp_path) {
    254             Ok(mut file) => {
    255                 if let Err(error) = write_and_sync(&mut file, bytes) {
    256                     let _ = fs::remove_file(&temp_path);
    257                     return Err(error).with_context(|| {
    258                         format!("failed to write config file {}", path.display())
    259                     });
    260                 }
    261                 drop(file);
    262                 if let Err(error) = fs::rename(&temp_path, path) {
    263                     let _ = fs::remove_file(&temp_path);
    264                     return Err(error).with_context(|| {
    265                         format!("failed to replace config file {}", path.display())
    266                     });
    267                 }
    268                 sync_parent_dir(path);
    269                 return Ok(());
    270             }
    271             Err(error)
    272                 if error
    273                     .downcast_ref::<std::io::Error>()
    274                     .is_some_and(|error| error.kind() == std::io::ErrorKind::AlreadyExists) =>
    275             {
    276                 continue;
    277             }
    278             Err(error) => return Err(error),
    279         }
    280     }
    281 
    282     bail!(
    283         "failed to create temporary config file for {}",
    284         path.display()
    285     )
    286 }
    287 
    288 fn create_config_file(path: &Path) -> Result<File> {
    289     let mut options = OpenOptions::new();
    290     options.write(true).create_new(true);
    291     #[cfg(unix)]
    292     options.mode(0o600);
    293     options
    294         .open(path)
    295         .with_context(|| format!("failed to create config file {}", path.display()))
    296 }
    297 
    298 fn write_and_sync(file: &mut File, bytes: &[u8]) -> Result<()> {
    299     file.write_all(bytes)?;
    300     file.sync_all()?;
    301     Ok(())
    302 }
    303 
    304 fn temp_file_path(path: &Path, attempt: u64) -> PathBuf {
    305     let file_name = path
    306         .file_name()
    307         .and_then(|name| name.to_str())
    308         .unwrap_or("config");
    309     let nanos = SystemTime::now()
    310         .duration_since(UNIX_EPOCH)
    311         .unwrap_or_default()
    312         .as_nanos();
    313     path.with_file_name(format!(
    314         ".{file_name}.{}.{}.tmp",
    315         std::process::id(),
    316         nanos.saturating_add(u128::from(attempt))
    317     ))
    318 }
    319 
    320 fn sync_parent_dir(path: &Path) {
    321     if let Some(parent) = path.parent() {
    322         if let Ok(dir) = File::open(parent) {
    323             let _ = dir.sync_all();
    324         }
    325     }
    326 }
    327 
    328 #[cfg(test)]
    329 mod tests {
    330     use std::fs;
    331 
    332     use tempfile::tempdir;
    333 
    334     use crate::domain::MICRO_IUNA;
    335 
    336     use super::{DEFAULT_BURN_AMOUNT, DEFAULT_BURN_FEE, UiConfig, load_or_create, save};
    337 
    338     #[test]
    339     fn creates_default_config_file() {
    340         let dir = tempdir().unwrap();
    341         let path = dir.path().join("config.json");
    342 
    343         let config = load_or_create(&path).unwrap();
    344 
    345         assert!(!config.setup_complete);
    346         let stored = fs::read_to_string(path).unwrap();
    347         assert!(stored.contains("\"version\": 1"));
    348         assert!(stored.contains("\"amount_unit\": \"microiuna\""));
    349         assert!(stored.contains("\"setup_complete\": false"));
    350         assert!(stored.contains("\"auth_password_hash\": null"));
    351         assert!(stored.contains("\"mining_enabled\": false"));
    352         assert!(stored.contains("\"pow_mining_enabled\": false"));
    353         assert!(stored.contains("\"pow_mining_workers\": 1"));
    354         assert!(stored.contains("\"burn_per_block\": 100"));
    355         assert!(stored.contains("\"burn_fee\": 1"));
    356         assert!(!stored.contains("\"pow_mine_fee\""));
    357         assert!(!stored.contains("required_burn"));
    358         assert!(stored.contains("\"keep_track_of_metrics\": false"));
    359         assert!(stored.contains("\"p2p_accept_inbound\": false"));
    360         assert!(stored.contains("\"p2p_bind_port\": 9444"));
    361         assert!(stored.contains("\"p2p_announce_addr\": null"));
    362         assert!(stored.contains("\"stratum_enabled\": false"));
    363         assert!(stored.contains("\"stratum_bind_port\": 3333"));
    364         assert!(stored.contains("\"wallet_endpoint_enabled\": false"));
    365         assert!(stored.contains("\"wallet_endpoint_bind_port\": 18662"));
    366         assert!(stored.contains("\"peers\": []"));
    367         assert!(stored.contains("\"address_book\": {}"));
    368     }
    369 
    370     #[test]
    371     fn saves_and_loads_setup_completion() {
    372         let dir = tempdir().unwrap();
    373         let path = dir.path().join("config.json");
    374 
    375         save(
    376             &path,
    377             &UiConfig {
    378                 setup_complete: true,
    379                 auth_password_hash: Some("auth-hash".to_string()),
    380                 mining_enabled: true,
    381                 pow_mining_enabled: true,
    382                 pow_mining_workers: 4,
    383                 burn_per_block: 50 * MICRO_IUNA,
    384                 burn_fee: 3 * MICRO_IUNA,
    385                 keep_track_of_metrics: true,
    386                 p2p_accept_inbound: true,
    387                 p2p_bind_port: 9555,
    388                 p2p_announce_addr: Some("203.0.113.10:9444".to_string()),
    389                 stratum_enabled: true,
    390                 stratum_bind_port: 3334,
    391                 wallet_endpoint_enabled: true,
    392                 wallet_endpoint_bind_port: 18663,
    393                 peers: vec!["127.0.0.1:9444".to_string()],
    394                 address_book: [("iuna-address".to_string(), "Alice".to_string())].into(),
    395                 ..UiConfig::default()
    396             },
    397         )
    398         .unwrap();
    399         let config = load_or_create(&path).unwrap();
    400 
    401         assert!(config.setup_complete);
    402         assert_eq!(config.auth_password_hash.as_deref(), Some("auth-hash"));
    403         assert!(config.mining_enabled);
    404         assert!(config.pow_mining_enabled);
    405         assert_eq!(config.pow_mining_workers, 4);
    406         assert_eq!(config.burn_per_block, 50 * MICRO_IUNA);
    407         assert_eq!(config.burn_fee, 3 * MICRO_IUNA);
    408         assert!(config.keep_track_of_metrics);
    409         assert!(config.p2p_accept_inbound);
    410         assert_eq!(config.p2p_bind_port, 9555);
    411         assert_eq!(
    412             config.p2p_announce_addr.as_deref(),
    413             Some("203.0.113.10:9444")
    414         );
    415         assert!(config.stratum_enabled);
    416         assert_eq!(config.stratum_bind_port, 3334);
    417         assert!(config.wallet_endpoint_enabled);
    418         assert_eq!(config.wallet_endpoint_bind_port, 18663);
    419         assert_eq!(config.peers, vec!["127.0.0.1:9444"]);
    420         assert_eq!(
    421             config.address_book.get("iuna-address"),
    422             Some(&"Alice".to_string())
    423         );
    424     }
    425 
    426     #[test]
    427     fn stale_atomic_temp_file_does_not_replace_saved_config() {
    428         let dir = tempdir().unwrap();
    429         let path = dir.path().join("config.json");
    430         let stale_temp = dir.path().join(".config.json.crash.tmp");
    431 
    432         save(
    433             &path,
    434             &UiConfig {
    435                 setup_complete: true,
    436                 mining_enabled: true,
    437                 peers: vec!["127.0.0.1:9444".to_string()],
    438                 ..UiConfig::default()
    439             },
    440         )
    441         .unwrap();
    442         fs::write(&stale_temp, b"{\"version\": 1,").unwrap();
    443 
    444         let config = load_or_create(&path).unwrap();
    445 
    446         assert!(config.setup_complete);
    447         assert!(config.mining_enabled);
    448         assert_eq!(config.peers, vec!["127.0.0.1:9444"]);
    449         assert!(stale_temp.exists());
    450     }
    451 
    452     #[test]
    453     fn ui_config_json_does_not_expose_password_hash() {
    454         let json = serde_json::to_string(&UiConfig {
    455             auth_password_hash: Some("secret-password-hash".to_string()),
    456             ..UiConfig::default()
    457         })
    458         .unwrap();
    459 
    460         assert!(!json.contains("secret-password-hash"));
    461         assert!(!json.contains("auth_password_hash"));
    462     }
    463 
    464     #[test]
    465     fn loads_old_config_without_burn_rate_as_zero() {
    466         let dir = tempdir().unwrap();
    467         let path = dir.path().join("config.json");
    468         fs::write(
    469             &path,
    470             r#"{
    471   "version": 1,
    472   "setup_complete": true,
    473   "peers": ["127.0.0.1:9444"]
    474 }
    475 "#,
    476         )
    477         .unwrap();
    478 
    479         let config = load_or_create(&path).unwrap();
    480 
    481         assert!(config.setup_complete);
    482         assert!(!config.mining_enabled);
    483         assert!(!config.pow_mining_enabled);
    484         assert_eq!(config.pow_mining_workers, 1);
    485         assert_eq!(config.burn_per_block, 0);
    486         assert_eq!(config.burn_fee, DEFAULT_BURN_FEE);
    487         assert!(!config.keep_track_of_metrics);
    488         assert!(!config.p2p_accept_inbound);
    489         assert_eq!(config.p2p_bind_port, 9444);
    490         assert!(!config.stratum_enabled);
    491         assert_eq!(config.stratum_bind_port, 3333);
    492         assert!(!config.wallet_endpoint_enabled);
    493         assert_eq!(config.wallet_endpoint_bind_port, 18662);
    494         assert_eq!(config.peers, vec!["127.0.0.1:9444"]);
    495         assert!(config.address_book.is_empty());
    496     }
    497 
    498     #[test]
    499     fn default_burn_settings_are_prefilled_while_disabled() {
    500         let config = UiConfig::default();
    501 
    502         assert!(!config.mining_enabled);
    503         assert_eq!(config.burn_per_block, DEFAULT_BURN_AMOUNT);
    504         assert_eq!(config.burn_fee, DEFAULT_BURN_FEE);
    505         assert_eq!(config.burn_per_block, 100);
    506         assert_eq!(config.burn_fee, 1);
    507     }
    508 
    509     #[test]
    510     fn legacy_config_with_announce_address_keeps_public_node_enabled() {
    511         let dir = tempdir().unwrap();
    512         let path = dir.path().join("config.json");
    513         fs::write(
    514             &path,
    515             r#"{
    516   "version": 1,
    517   "setup_complete": true,
    518   "p2p_announce_addr": "203.0.113.10:9444",
    519   "peers": []
    520 }
    521 "#,
    522         )
    523         .unwrap();
    524 
    525         let config = load_or_create(&path).unwrap();
    526 
    527         assert!(config.p2p_accept_inbound);
    528         assert_eq!(
    529             config.p2p_announce_addr.as_deref(),
    530             Some("203.0.113.10:9444")
    531         );
    532     }
    533 
    534     #[test]
    535     fn loads_old_config_with_burn_rate_as_mining_enabled() {
    536         let dir = tempdir().unwrap();
    537         let path = dir.path().join("config.json");
    538         fs::write(
    539             &path,
    540             r#"{
    541   "version": 1,
    542   "setup_complete": true,
    543   "burn_per_block": 2,
    544   "burn_fee": 1,
    545   "peers": []
    546 }
    547 "#,
    548         )
    549         .unwrap();
    550 
    551         let config = load_or_create(&path).unwrap();
    552 
    553         assert!(config.mining_enabled);
    554         assert_eq!(config.burn_per_block, 2 * MICRO_IUNA);
    555         assert_eq!(config.burn_fee, MICRO_IUNA);
    556     }
    557 
    558     #[test]
    559     fn loads_renamed_legacy_micro_unit_config_without_rescaling_amounts() {
    560         let dir = tempdir().unwrap();
    561         let path = dir.path().join("config.json");
    562         let legacy_unit = format!("micro{}{}", "l", "uun");
    563         fs::write(
    564             &path,
    565             format!(
    566                 r#"{{
    567   "version": 1,
    568   "amount_unit": "{legacy_unit}",
    569   "setup_complete": true,
    570   "mining_enabled": true,
    571   "burn_per_block": 2000000,
    572   "burn_fee": 3,
    573   "pow_mine_fee": 5,
    574   "peers": []
    575 }}
    576 "#
    577             ),
    578         )
    579         .unwrap();
    580 
    581         let config = load_or_create(&path).unwrap();
    582 
    583         assert!(config.mining_enabled);
    584         assert_eq!(config.burn_per_block, 2 * MICRO_IUNA);
    585         assert_eq!(config.burn_fee, 3);
    586     }
    587 }