iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

config_store.rs (13935B)


      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,
      8 };
      9 
     10 use anyhow::{Context, Result, bail};
     11 use serde::{Deserialize, Serialize};
     12 
     13 use crate::domain::{Amount, MICRO_IUNA};
     14 
     15 const CONFIG_FILE_VERSION: u32 = 1;
     16 const AMOUNT_UNIT_MICROIUNA: &str = "microiuna";
     17 const LEGACY_AMOUNT_UNIT_PRE_RENAME: &str = concat!("micro", "l", "uun");
     18 pub const DEFAULT_BURN_AMOUNT: Amount = MICRO_IUNA / 10_000;
     19 pub const DEFAULT_BURN_FEE: Amount = DEFAULT_BURN_AMOUNT;
     20 pub const DEFAULT_RECOVERY_VDF_TOP_RANK_PERCENT: u8 = 50;
     21 pub const DEFAULT_POW_MINING_WORKERS: u8 = 1;
     22 pub const MAX_POW_MINING_WORKERS: u8 = 32;
     23 pub const DEFAULT_P2P_BIND_PORT: u16 = 9444;
     24 
     25 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
     26 pub struct UiConfig {
     27     pub setup_complete: bool,
     28     #[serde(skip_serializing, default)]
     29     pub auth_password_hash: Option<String>,
     30     pub mining_enabled: bool,
     31     pub pow_mining_enabled: bool,
     32     pub pow_mining_workers: u8,
     33     pub burn_per_block: Amount,
     34     pub burn_fee: Amount,
     35     pub recovery_vdf_top_rank_percent: u8,
     36     pub keep_track_of_metrics: bool,
     37     pub p2p_accept_inbound: bool,
     38     pub p2p_bind_port: u16,
     39     pub p2p_announce_addr: Option<String>,
     40     pub peers: Vec<String>,
     41     pub address_book: BTreeMap<String, String>,
     42 }
     43 
     44 impl Default for UiConfig {
     45     fn default() -> Self {
     46         Self {
     47             setup_complete: false,
     48             auth_password_hash: None,
     49             mining_enabled: false,
     50             pow_mining_enabled: false,
     51             pow_mining_workers: DEFAULT_POW_MINING_WORKERS,
     52             burn_per_block: DEFAULT_BURN_AMOUNT,
     53             burn_fee: DEFAULT_BURN_FEE,
     54             recovery_vdf_top_rank_percent: DEFAULT_RECOVERY_VDF_TOP_RANK_PERCENT,
     55             keep_track_of_metrics: false,
     56             p2p_accept_inbound: false,
     57             p2p_bind_port: DEFAULT_P2P_BIND_PORT,
     58             p2p_announce_addr: None,
     59             peers: Vec::new(),
     60             address_book: BTreeMap::new(),
     61         }
     62     }
     63 }
     64 
     65 #[derive(Debug, Deserialize, Serialize)]
     66 struct ConfigFile {
     67     version: u32,
     68     #[serde(default)]
     69     amount_unit: Option<String>,
     70     setup_complete: bool,
     71     #[serde(default)]
     72     auth_password_hash: Option<String>,
     73     #[serde(default)]
     74     mining_enabled: Option<bool>,
     75     #[serde(default)]
     76     pow_mining_enabled: bool,
     77     #[serde(default = "default_pow_mining_workers")]
     78     pow_mining_workers: u8,
     79     #[serde(default)]
     80     burn_per_block: Amount,
     81     #[serde(default)]
     82     burn_fee: Option<Amount>,
     83     #[serde(default)]
     84     recovery_vdf_top_rank_percent: Option<u8>,
     85     #[serde(default)]
     86     keep_track_of_metrics: bool,
     87     #[serde(default)]
     88     p2p_accept_inbound: Option<bool>,
     89     #[serde(default = "default_p2p_bind_port")]
     90     p2p_bind_port: u16,
     91     #[serde(default)]
     92     p2p_announce_addr: Option<String>,
     93     #[serde(default)]
     94     peers: Vec<String>,
     95     #[serde(default)]
     96     address_book: BTreeMap<String, String>,
     97 }
     98 
     99 pub fn load_or_create(path: &Path) -> Result<UiConfig> {
    100     if path.exists() {
    101         return load(path);
    102     }
    103 
    104     let config = UiConfig::default();
    105     save(path, &config)?;
    106     Ok(config)
    107 }
    108 
    109 pub fn save(path: &Path, config: &UiConfig) -> Result<()> {
    110     if let Some(parent) = path.parent() {
    111         fs::create_dir_all(parent)
    112             .with_context(|| format!("failed to create config directory {}", parent.display()))?;
    113     }
    114 
    115     let stored = ConfigFile {
    116         version: CONFIG_FILE_VERSION,
    117         amount_unit: Some(AMOUNT_UNIT_MICROIUNA.to_string()),
    118         setup_complete: config.setup_complete,
    119         auth_password_hash: config.auth_password_hash.clone(),
    120         mining_enabled: Some(config.mining_enabled),
    121         pow_mining_enabled: config.pow_mining_enabled,
    122         pow_mining_workers: clamp_pow_mining_workers(config.pow_mining_workers),
    123         burn_per_block: config.burn_per_block,
    124         burn_fee: Some(config.burn_fee),
    125         recovery_vdf_top_rank_percent: Some(config.recovery_vdf_top_rank_percent),
    126         keep_track_of_metrics: config.keep_track_of_metrics,
    127         p2p_accept_inbound: Some(config.p2p_accept_inbound),
    128         p2p_bind_port: config.p2p_bind_port,
    129         p2p_announce_addr: config.p2p_announce_addr.clone(),
    130         peers: config.peers.clone(),
    131         address_book: config.address_book.clone(),
    132     };
    133     let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize config file")?;
    134     let mut file = create_config_file(path)?;
    135     file.write_all(&bytes)
    136         .with_context(|| format!("failed to write config file {}", path.display()))?;
    137     file.write_all(b"\n")
    138         .with_context(|| format!("failed to write config file {}", path.display()))?;
    139     Ok(())
    140 }
    141 
    142 fn load(path: &Path) -> Result<UiConfig> {
    143     let bytes =
    144         fs::read(path).with_context(|| format!("failed to read config file {}", path.display()))?;
    145     let stored: ConfigFile = serde_json::from_slice(&bytes)
    146         .with_context(|| format!("failed to parse config file {}", path.display()))?;
    147 
    148     if stored.version != CONFIG_FILE_VERSION {
    149         bail!(
    150             "unsupported config file version {} in {}",
    151             stored.version,
    152             path.display()
    153         );
    154     }
    155 
    156     let scale = if matches!(
    157         stored.amount_unit.as_deref(),
    158         Some(AMOUNT_UNIT_MICROIUNA) | Some(LEGACY_AMOUNT_UNIT_PRE_RENAME)
    159     ) {
    160         1
    161     } else {
    162         MICRO_IUNA
    163     };
    164 
    165     let p2p_accept_inbound = stored
    166         .p2p_accept_inbound
    167         .unwrap_or_else(|| stored.p2p_announce_addr.is_some());
    168 
    169     Ok(UiConfig {
    170         setup_complete: stored.setup_complete,
    171         auth_password_hash: stored.auth_password_hash,
    172         mining_enabled: stored.mining_enabled.unwrap_or(stored.burn_per_block > 0),
    173         pow_mining_enabled: stored.pow_mining_enabled,
    174         pow_mining_workers: clamp_pow_mining_workers(stored.pow_mining_workers),
    175         burn_per_block: stored.burn_per_block.saturating_mul(scale),
    176         burn_fee: stored
    177             .burn_fee
    178             .map(|fee| fee.saturating_mul(scale))
    179             .unwrap_or(DEFAULT_BURN_FEE),
    180         recovery_vdf_top_rank_percent: stored
    181             .recovery_vdf_top_rank_percent
    182             .unwrap_or(DEFAULT_RECOVERY_VDF_TOP_RANK_PERCENT)
    183             .min(100),
    184         keep_track_of_metrics: stored.keep_track_of_metrics,
    185         p2p_accept_inbound,
    186         p2p_bind_port: stored.p2p_bind_port,
    187         p2p_announce_addr: stored.p2p_announce_addr,
    188         peers: stored.peers,
    189         address_book: stored.address_book,
    190     })
    191 }
    192 
    193 pub fn clamp_pow_mining_workers(workers: u8) -> u8 {
    194     workers.clamp(1, MAX_POW_MINING_WORKERS)
    195 }
    196 
    197 fn default_pow_mining_workers() -> u8 {
    198     DEFAULT_POW_MINING_WORKERS
    199 }
    200 
    201 fn default_p2p_bind_port() -> u16 {
    202     DEFAULT_P2P_BIND_PORT
    203 }
    204 
    205 fn create_config_file(path: &Path) -> Result<File> {
    206     let mut options = OpenOptions::new();
    207     options.write(true).create(true).truncate(true);
    208     #[cfg(unix)]
    209     options.mode(0o600);
    210     options
    211         .open(path)
    212         .with_context(|| format!("failed to create config file {}", path.display()))
    213 }
    214 
    215 #[cfg(test)]
    216 mod tests {
    217     use std::fs;
    218 
    219     use tempfile::tempdir;
    220 
    221     use crate::domain::MICRO_IUNA;
    222 
    223     use super::{DEFAULT_BURN_AMOUNT, DEFAULT_BURN_FEE, UiConfig, load_or_create, save};
    224 
    225     #[test]
    226     fn creates_default_config_file() {
    227         let dir = tempdir().unwrap();
    228         let path = dir.path().join("config.json");
    229 
    230         let config = load_or_create(&path).unwrap();
    231 
    232         assert!(!config.setup_complete);
    233         let stored = fs::read_to_string(path).unwrap();
    234         assert!(stored.contains("\"version\": 1"));
    235         assert!(stored.contains("\"amount_unit\": \"microiuna\""));
    236         assert!(stored.contains("\"setup_complete\": false"));
    237         assert!(stored.contains("\"auth_password_hash\": null"));
    238         assert!(stored.contains("\"mining_enabled\": false"));
    239         assert!(stored.contains("\"pow_mining_enabled\": false"));
    240         assert!(stored.contains("\"pow_mining_workers\": 1"));
    241         assert!(stored.contains("\"burn_per_block\": 100"));
    242         assert!(stored.contains("\"burn_fee\": 100"));
    243         assert!(!stored.contains("\"pow_mine_fee\""));
    244         assert!(!stored.contains("required_burn"));
    245         assert!(stored.contains("\"keep_track_of_metrics\": false"));
    246         assert!(stored.contains("\"p2p_accept_inbound\": false"));
    247         assert!(stored.contains("\"p2p_bind_port\": 9444"));
    248         assert!(stored.contains("\"p2p_announce_addr\": null"));
    249         assert!(stored.contains("\"peers\": []"));
    250         assert!(stored.contains("\"address_book\": {}"));
    251     }
    252 
    253     #[test]
    254     fn saves_and_loads_setup_completion() {
    255         let dir = tempdir().unwrap();
    256         let path = dir.path().join("config.json");
    257 
    258         save(
    259             &path,
    260             &UiConfig {
    261                 setup_complete: true,
    262                 auth_password_hash: Some("auth-hash".to_string()),
    263                 mining_enabled: true,
    264                 pow_mining_enabled: true,
    265                 pow_mining_workers: 4,
    266                 burn_per_block: 50 * MICRO_IUNA,
    267                 burn_fee: 3 * MICRO_IUNA,
    268                 keep_track_of_metrics: true,
    269                 p2p_accept_inbound: true,
    270                 p2p_bind_port: 9555,
    271                 p2p_announce_addr: Some("203.0.113.10:9444".to_string()),
    272                 peers: vec!["127.0.0.1:9444".to_string()],
    273                 address_book: [("iuna-address".to_string(), "Alice".to_string())].into(),
    274                 ..UiConfig::default()
    275             },
    276         )
    277         .unwrap();
    278         let config = load_or_create(&path).unwrap();
    279 
    280         assert!(config.setup_complete);
    281         assert_eq!(config.auth_password_hash.as_deref(), Some("auth-hash"));
    282         assert!(config.mining_enabled);
    283         assert!(config.pow_mining_enabled);
    284         assert_eq!(config.pow_mining_workers, 4);
    285         assert_eq!(config.burn_per_block, 50 * MICRO_IUNA);
    286         assert_eq!(config.burn_fee, 3 * MICRO_IUNA);
    287         assert!(config.keep_track_of_metrics);
    288         assert!(config.p2p_accept_inbound);
    289         assert_eq!(config.p2p_bind_port, 9555);
    290         assert_eq!(
    291             config.p2p_announce_addr.as_deref(),
    292             Some("203.0.113.10:9444")
    293         );
    294         assert_eq!(config.peers, vec!["127.0.0.1:9444"]);
    295         assert_eq!(
    296             config.address_book.get("iuna-address"),
    297             Some(&"Alice".to_string())
    298         );
    299     }
    300 
    301     #[test]
    302     fn ui_config_json_does_not_expose_password_hash() {
    303         let json = serde_json::to_string(&UiConfig {
    304             auth_password_hash: Some("secret-password-hash".to_string()),
    305             ..UiConfig::default()
    306         })
    307         .unwrap();
    308 
    309         assert!(!json.contains("secret-password-hash"));
    310         assert!(!json.contains("auth_password_hash"));
    311     }
    312 
    313     #[test]
    314     fn loads_old_config_without_burn_rate_as_zero() {
    315         let dir = tempdir().unwrap();
    316         let path = dir.path().join("config.json");
    317         fs::write(
    318             &path,
    319             r#"{
    320   "version": 1,
    321   "setup_complete": true,
    322   "peers": ["127.0.0.1:9444"]
    323 }
    324 "#,
    325         )
    326         .unwrap();
    327 
    328         let config = load_or_create(&path).unwrap();
    329 
    330         assert!(config.setup_complete);
    331         assert!(!config.mining_enabled);
    332         assert!(!config.pow_mining_enabled);
    333         assert_eq!(config.pow_mining_workers, 1);
    334         assert_eq!(config.burn_per_block, 0);
    335         assert_eq!(config.burn_fee, DEFAULT_BURN_FEE);
    336         assert!(!config.keep_track_of_metrics);
    337         assert!(!config.p2p_accept_inbound);
    338         assert_eq!(config.p2p_bind_port, 9444);
    339         assert_eq!(config.peers, vec!["127.0.0.1:9444"]);
    340         assert!(config.address_book.is_empty());
    341     }
    342 
    343     #[test]
    344     fn default_burn_settings_are_prefilled_while_disabled() {
    345         let config = UiConfig::default();
    346 
    347         assert!(!config.mining_enabled);
    348         assert_eq!(config.burn_per_block, DEFAULT_BURN_AMOUNT);
    349         assert_eq!(config.burn_fee, DEFAULT_BURN_FEE);
    350     }
    351 
    352     #[test]
    353     fn legacy_config_with_announce_address_keeps_public_node_enabled() {
    354         let dir = tempdir().unwrap();
    355         let path = dir.path().join("config.json");
    356         fs::write(
    357             &path,
    358             r#"{
    359   "version": 1,
    360   "setup_complete": true,
    361   "p2p_announce_addr": "203.0.113.10:9444",
    362   "peers": []
    363 }
    364 "#,
    365         )
    366         .unwrap();
    367 
    368         let config = load_or_create(&path).unwrap();
    369 
    370         assert!(config.p2p_accept_inbound);
    371         assert_eq!(
    372             config.p2p_announce_addr.as_deref(),
    373             Some("203.0.113.10:9444")
    374         );
    375     }
    376 
    377     #[test]
    378     fn loads_old_config_with_burn_rate_as_mining_enabled() {
    379         let dir = tempdir().unwrap();
    380         let path = dir.path().join("config.json");
    381         fs::write(
    382             &path,
    383             r#"{
    384   "version": 1,
    385   "setup_complete": true,
    386   "burn_per_block": 2,
    387   "burn_fee": 1,
    388   "peers": []
    389 }
    390 "#,
    391         )
    392         .unwrap();
    393 
    394         let config = load_or_create(&path).unwrap();
    395 
    396         assert!(config.mining_enabled);
    397         assert_eq!(config.burn_per_block, 2 * MICRO_IUNA);
    398         assert_eq!(config.burn_fee, MICRO_IUNA);
    399     }
    400 
    401     #[test]
    402     fn loads_renamed_legacy_micro_unit_config_without_rescaling_amounts() {
    403         let dir = tempdir().unwrap();
    404         let path = dir.path().join("config.json");
    405         let legacy_unit = format!("micro{}{}", "l", "uun");
    406         fs::write(
    407             &path,
    408             format!(
    409                 r#"{{
    410   "version": 1,
    411   "amount_unit": "{legacy_unit}",
    412   "setup_complete": true,
    413   "mining_enabled": true,
    414   "burn_per_block": 2000000,
    415   "burn_fee": 3,
    416   "pow_mine_fee": 5,
    417   "peers": []
    418 }}
    419 "#
    420             ),
    421         )
    422         .unwrap();
    423 
    424         let config = load_or_create(&path).unwrap();
    425 
    426         assert!(config.mining_enabled);
    427         assert_eq!(config.burn_per_block, 2 * MICRO_IUNA);
    428         assert_eq!(config.burn_fee, 3);
    429     }
    430 }