diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..4a6b4547 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +test_data/file-injection/** -text diff --git a/pallas-configs/README.md b/pallas-configs/README.md index 7c83d617..ac08580c 100644 --- a/pallas-configs/README.md +++ b/pallas-configs/README.md @@ -27,3 +27,5 @@ if let Some(staking) = config.staking { a `GenesisFile` (or equivalent) struct and a `from_file` helper. - `cost_models` — typed views over Plutus cost-model tables, shared across eras. +- `injection` holds the `extraConfig` injection sources and the rule that picks + between an injected value and the top-level field it stands in for. diff --git a/pallas-configs/src/injection.rs b/pallas-configs/src/injection.rs new file mode 100644 index 00000000..1d69b916 --- /dev/null +++ b/pallas-configs/src/injection.rs @@ -0,0 +1,581 @@ +//! Genesis fields written under `extraConfig` instead of at the top level. +//! +//! Current tooling writes a chain's starting funds, pools and delegations +//! under `extraConfig` and leaves the old top level fields empty. A reader +//! that only looks at the top level sees an empty chain and no error. The +//! shapes and the rule for choosing between the two places are the same in +//! the shelley and conway files, so they live here. + +use pallas_crypto::hash::{Hash, Hasher}; +use serde::{Deserialize, Deserializer, de::DeserializeOwned}; +use std::{collections::HashMap, path::PathBuf, str::FromStr}; + +/// The raw keys of one `extraConfig` entry, before it is checked. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +// serde would otherwise require `T: Default` because of the defaulted +// fields, but `Option` defaults to `None` for any `T`. +#[serde(bound(deserialize = "T: Deserialize<'de>"))] +struct InjectionRaw { + #[serde(default)] + data: Option, + #[serde(default)] + file: Option>, + #[serde(default)] + hash: Option, +} + +/// One `extraConfig` entry. +/// +/// It is one of three things: the data written inline, a pointer to a file +/// holding the data, or nothing. Confusing them gives a genesis that parses +/// cleanly and is wrong. +#[derive(Debug, Clone)] +pub enum Injection { + /// The data is written inline under `data`. + Embedded(T), + + /// The data is in a separate file, given as path segments and the hash + /// the file must have. + FromFile { file: Vec, hash: String }, + + /// No `data` and no `file`, so this entry injects nothing. + Absent, +} + +impl TryFrom> for Injection { + type Error = String; + + fn try_from(raw: InjectionRaw) -> Result { + match (raw.data, raw.file) { + (Some(_), Some(_)) => { + Err("an injection names both a data payload and a file".to_string()) + } + (Some(data), None) => Ok(Self::Embedded(data)), + (None, Some(file)) => { + let hash = raw + .hash + .ok_or_else(|| "an injection file is named without its hash".to_string())?; + + Ok(Self::FromFile { file, hash }) + } + (None, None) => Ok(Self::Absent), + } + } +} + +impl<'de, T> Deserialize<'de> for Injection +where + T: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = InjectionRaw::::deserialize(deserializer)?; + + Self::try_from(raw).map_err(serde::de::Error::custom) + } +} + +/// Where an injection file may be read from. +#[derive(Debug, Clone)] +pub(crate) enum Source { + /// No file may be read, so a file injection is refused. + NoFilesystem, + + /// Injection files are read under the shelley genesis file's directory, + /// which the node mounts for every era. + Directory(PathBuf), +} + +impl Source { + /// Read and parse one injection file, hashing its bytes and not the parsed data. + fn read( + &self, + injected_name: &str, + file: &[String], + hash: &str, + ) -> Result, String> + where + K: std::hash::Hash + Eq + DeserializeOwned, + V: DeserializeOwned, + { + let directory = match self { + Self::NoFilesystem => { + return Err(format!( + "extraConfig.{injected_name} names an injection file ({}), which cannot be read while parsing", + file.join("/") + )); + } + Self::Directory(directory) => directory, + }; + + for segment in file { + let names_one_entry = !segment.is_empty() + && segment != "." + && segment != ".." + && !std::path::Path::new(segment).is_absolute() + && !segment.contains('/') + && !segment.contains('\\'); + + if !names_one_entry { + return Err(format!( + "extraConfig.{injected_name} names the path segment {segment:?}, which is not one file or directory name" + )); + } + } + + let expected = Hash::<32>::from_str(hash).map_err(|_| { + format!( + "extraConfig.{injected_name} names the hash {hash}, which is not a blake2b-256 hash" + ) + })?; + + let path = file + .iter() + .fold(directory.clone(), |path, segment| path.join(segment)); + let named = path.display(); + + let bytes = std::fs::read(&path).map_err(|err| { + format!("the injection file {named} that extraConfig.{injected_name} names cannot be read ({err})") + })?; + + let found = Hasher::<256>::hash(&bytes); + if found != expected { + return Err(format!( + "the injection file {named} hashes to {found}, not the {expected} that extraConfig.{injected_name} names" + )); + } + + serde_json::from_slice(&bytes).map_err(|err| { + format!("the injection file {named} does not hold the map extraConfig.{injected_name} stands for ({err})") + }) + } +} + +/// Pick which of the two places a genesis field was written in. +/// +/// The top level is used when there is no injection, and the injection +/// otherwise. Both sides holding entries is refused before any file is read. +/// +/// `injected_name` is the key under `extraConfig` and `top_level_name` is +/// the field it replaces. Both are only used in error messages. +pub(crate) fn resolve( + source: &Source, + injected_name: &str, + top_level_name: &str, + injection: Option>>, + top_level: Option>, +) -> Result>, String> +where + K: std::hash::Hash + Eq + DeserializeOwned, + V: DeserializeOwned, +{ + match injection { + None | Some(Injection::Absent) => Ok(top_level), + Some(Injection::Embedded(injected)) => { + refuse_two_sources(injected_name, top_level_name, top_level.as_ref())?; + + Ok(Some(injected)) + } + Some(Injection::FromFile { file, hash }) => { + refuse_two_sources(injected_name, top_level_name, top_level.as_ref())?; + + source.read(injected_name, &file, &hash).map(Some) + } + } +} + +fn refuse_two_sources( + injected_name: &str, + top_level_name: &str, + top_level: Option<&HashMap>, +) -> Result<(), String> { + match top_level { + Some(top_level) if !top_level.is_empty() => Err(format!( + "extraConfig.{injected_name} and {top_level_name} are both populated, so the genesis names two sources for one field" + )), + _ => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + type Funds = HashMap; + + fn parse(json: &str) -> Result, serde_json::Error> { + serde_json::from_str(json) + } + + fn funds(entries: &[(&str, u64)]) -> Funds { + entries + .iter() + .map(|(key, value)| ((*key).to_string(), *value)) + .collect() + } + + #[test] + fn an_embedded_payload_is_read() { + match parse(r#"{ "data": { "aa": 7 } }"#).expect("the injection must parse") { + Injection::Embedded(payload) => assert_eq!(payload, funds(&[("aa", 7)])), + other => panic!("expected an embedded payload, got {other:?}"), + } + } + + #[test] + fn an_empty_object_names_no_injection() { + match parse("{}").expect("the injection must parse") { + Injection::Absent => {} + other => panic!("expected no injection, got {other:?}"), + } + } + + #[test] + fn a_file_arm_keeps_path_and_hash() { + let json = r#"{ "file": ["genesis", "funds.json"], "hash": "abcd" }"#; + + match parse(json).expect("the injection must parse") { + Injection::FromFile { file, hash } => { + assert_eq!(file, vec!["genesis".to_string(), "funds.json".to_string()]); + assert_eq!(hash, "abcd"); + } + other => panic!("expected a file injection, got {other:?}"), + } + } + + #[test] + fn a_malformed_payload_is_refused() { + let err = parse(r#"{ "data": { "aa": "not a number" } }"#) + .expect_err("a payload that does not parse must be refused"); + + assert!(err.to_string().contains("invalid type"), "{err}"); + } + + #[test] + fn both_arms_at_once_is_refused() { + let json = r#"{ "data": { "aa": 7 }, "file": ["funds.json"], "hash": "abcd" }"#; + + let err = parse(json).expect_err("an injection with two sources must be refused"); + + assert!(err.to_string().contains("both a data payload"), "{err}"); + } + + #[test] + fn an_unknown_key_is_refused() { + let err = parse(r#"{ "datum": { "aa": 7 } }"#) + .expect_err("an injection with an unmodelled key must be refused"); + + assert!(err.to_string().contains("unknown field"), "{err}"); + assert!(err.to_string().contains("datum"), "{err}"); + } + + #[test] + fn a_file_arm_needs_its_hash() { + let err = parse(r#"{ "file": ["funds.json"] }"#) + .expect_err("a file injection with no hash must be refused"); + + assert!(err.to_string().contains("without its hash"), "{err}"); + } + + #[test] + fn no_injection_reads_the_top_level() { + let top_level = funds(&[("aa", 7)]); + + let resolved = resolve( + &Source::NoFilesystem, + "initialFunds", + "initialFunds", + None, + Some(top_level.clone()), + ) + .expect("no injection must resolve"); + assert_eq!(resolved, Some(top_level.clone())); + + let resolved = resolve( + &Source::NoFilesystem, + "initialFunds", + "initialFunds", + Some(Injection::Absent), + Some(top_level.clone()), + ) + .expect("an absent injection must resolve"); + assert_eq!(resolved, Some(top_level)); + } + + #[test] + fn an_injection_beats_an_empty_top_level() { + let injected = funds(&[("bb", 9)]); + + let resolved = resolve( + &Source::NoFilesystem, + "initialFunds", + "initialFunds", + Some(Injection::Embedded(injected.clone())), + Some(Funds::new()), + ) + .expect("an injection against an empty map must resolve"); + assert_eq!(resolved, Some(injected.clone())); + + let resolved = resolve( + &Source::NoFilesystem, + "initialFunds", + "initialFunds", + Some(Injection::Embedded(injected.clone())), + None, + ) + .expect("an injection against a missing field must resolve"); + assert_eq!(resolved, Some(injected)); + } + + #[test] + fn an_empty_payload_against_a_populated_top_level_is_refused() { + let err = resolve( + &Source::NoFilesystem, + "initialFunds", + "initialFunds", + Some(Injection::Embedded(Funds::new())), + Some(funds(&[("aa", 7)])), + ) + .expect_err("an empty payload against a populated top level is two sources"); + + assert!(err.contains("both populated"), "{err}"); + } + + #[test] + fn two_empty_sources_stay_empty() { + let resolved = resolve( + &Source::NoFilesystem, + "stakePools", + "staking.pools", + Some(Injection::Embedded(Funds::new())), + Some(Funds::new()), + ) + .expect("two empty sources must resolve"); + + assert_eq!(resolved, Some(Funds::new())); + } + + // Disjoint entries on purpose, so the refusal rests on both maps being + // populated rather than on their keys clashing. + #[test] + fn both_sources_populated_is_refused() { + let err = resolve( + &Source::NoFilesystem, + "initialFunds", + "initialFunds", + Some(Injection::Embedded(funds(&[("bb", 9)]))), + Some(funds(&[("aa", 7)])), + ) + .expect_err("a field with two sources must be refused"); + + assert!(err.contains("initialFunds"), "{err}"); + assert!(err.contains("both populated"), "{err}"); + } + + #[test] + fn the_text_only_path_refuses_a_file_arm() { + let err = resolve( + &Source::NoFilesystem, + "stakePools", + "staking.pools", + Some(Injection::FromFile { + file: vec!["genesis".to_string(), "pools.json".to_string()], + hash: "abcd".to_string(), + }), + Some(Funds::new()), + ) + .expect_err("an injection this cannot read must be refused"); + + assert!(err.contains("stakePools"), "{err}"); + assert!(err.contains("genesis/pools.json"), "{err}"); + } + + const INJECTED_FILE: [&str; 2] = ["file-injection", "initial-funds.json"]; + const INJECTED_FILE_HASH: &str = + "5f5ef4cb568ce42c470afcf6bfbca574ae345e1323b26be0de40d471db835ad7"; + + fn test_data() -> PathBuf { + PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) + .join("..") + .join("test_data") + } + + fn resolve_file(file: &[&str], hash: &str) -> Result, String> { + resolve( + &Source::Directory(test_data()), + "initialFunds", + "initialFunds", + Some(Injection::FromFile { + file: file.iter().map(|segment| (*segment).to_string()).collect(), + hash: hash.to_string(), + }), + Some(Funds::new()), + ) + } + + #[test] + fn an_injection_file_is_read() { + let resolved = resolve_file(&INJECTED_FILE, INJECTED_FILE_HASH) + .expect("the injection file must resolve") + .expect("the injection file must carry funds"); + + assert_eq!(resolved.len(), 3); + assert_eq!( + resolved.get("6004d2cf712cfcaafb8bda85dc31baf3a35168d2e28029e0b56c562d37"), + Some(&2_250_000_000_000), + ); + } + + #[test] + fn a_wrong_hash_is_refused() { + let wrong = "0000000000000000000000000000000000000000000000000000000000000000"; + + let err = resolve_file(&INJECTED_FILE, wrong) + .expect_err("a file that does not hash to what the genesis names must be refused"); + + assert!(err.contains("initial-funds.json"), "{err}"); + assert!(err.contains(INJECTED_FILE_HASH), "{err}"); + assert!(err.contains(wrong), "{err}"); + } + + #[test] + fn a_missing_file_is_refused() { + let err = resolve_file(&["file-injection", "absent.json"], INJECTED_FILE_HASH) + .expect_err("a file that is not there must be refused"); + + assert!(err.contains("absent.json"), "{err}"); + assert!( + err.contains("that extraConfig.initialFunds names cannot be read"), + "{err}" + ); + } + + #[test] + fn a_hash_that_is_not_a_hash_is_refused() { + let err = resolve_file(&INJECTED_FILE, "abcd") + .expect_err("an injection hash that is not a hash must be refused"); + + assert!(err.contains("abcd"), "{err}"); + assert!(err.contains("blake2b-256"), "{err}"); + } + + #[test] + fn a_file_holding_something_else_is_refused() { + let file = ["file-injection-shelley-genesis.json"]; + let bytes = std::fs::read(test_data().join(file[0])).expect("the fixture must be there"); + let hash = Hasher::<256>::hash(&bytes).to_string(); + + let err = resolve_file(&file, &hash) + .expect_err("a file that does not hold the field's map must be refused"); + + assert!(err.contains("file-injection-shelley-genesis.json"), "{err}"); + assert!( + err.contains("does not hold the map extraConfig.initialFunds"), + "{err}" + ); + } + + #[test] + fn a_file_arm_against_a_populated_top_level_is_refused() { + let err = resolve( + &Source::Directory(test_data()), + "initialFunds", + "initialFunds", + Some(Injection::FromFile { + file: INJECTED_FILE + .iter() + .map(|segment| (*segment).to_string()) + .collect(), + hash: INJECTED_FILE_HASH.to_string(), + }), + Some(funds(&[("aa", 7)])), + ) + .expect_err("a field with two sources must be refused"); + + assert!(err.contains("initialFunds"), "{err}"); + assert!(err.contains("both populated"), "{err}"); + } + + #[test] + fn a_file_arm_against_a_populated_top_level_is_refused_before_the_read() { + let err = resolve( + &Source::Directory(test_data()), + "initialFunds", + "initialFunds", + Some(Injection::FromFile { + file: vec!["file-injection".to_string(), "absent.json".to_string()], + hash: INJECTED_FILE_HASH.to_string(), + }), + Some(funds(&[("aa", 7)])), + ) + .expect_err("a field with two sources must be refused"); + + assert!(err.contains("both populated"), "{err}"); + assert!(!err.contains("cannot be read"), "{err}"); + } + + #[test] + fn an_empty_segment_is_refused() { + let err = resolve_file( + &["", INJECTED_FILE[0], INJECTED_FILE[1]], + INJECTED_FILE_HASH, + ) + .expect_err("an empty segment must be refused"); + + assert!(err.contains("initialFunds"), "{err}"); + assert!(err.contains(r#""""#), "{err}"); + } + + #[test] + fn a_current_directory_segment_is_refused() { + let err = resolve_file( + &[".", INJECTED_FILE[0], INJECTED_FILE[1]], + INJECTED_FILE_HASH, + ) + .expect_err("a current directory segment must be refused"); + + assert!(err.contains("initialFunds"), "{err}"); + assert!(err.contains(r#"".""#), "{err}"); + } + + #[test] + fn a_parent_segment_is_refused() { + let err = resolve_file( + &["..", "test_data", INJECTED_FILE[0], INJECTED_FILE[1]], + INJECTED_FILE_HASH, + ) + .expect_err("a parent segment must be refused"); + + assert!(err.contains("initialFunds"), "{err}"); + assert!(err.contains(r#""..""#), "{err}"); + } + + #[test] + fn an_absolute_segment_is_refused() { + let absolute = test_data() + .join(INJECTED_FILE[0]) + .join(INJECTED_FILE[1]) + .display() + .to_string(); + + let err = resolve_file(&[&absolute], INJECTED_FILE_HASH) + .expect_err("an absolute segment must be refused"); + + assert!(err.contains("initialFunds"), "{err}"); + assert!(err.contains("not one file or directory name"), "{err}"); + } + + #[test] + fn a_segment_holding_a_separator_is_refused() { + let joined = INJECTED_FILE.join("/"); + + let err = resolve_file(&[&joined], INJECTED_FILE_HASH) + .expect_err("a segment holding a path separator must be refused"); + + assert!(err.contains("initialFunds"), "{err}"); + assert!(err.contains("file-injection/initial-funds.json"), "{err}"); + } +} diff --git a/pallas-configs/src/lib.rs b/pallas-configs/src/lib.rs index 938805be..43689bea 100644 --- a/pallas-configs/src/lib.rs +++ b/pallas-configs/src/lib.rs @@ -28,6 +28,9 @@ //! helper. //! - [`cost_models`] — typed views over Plutus cost-model tables, shared //! across eras. +//! - [`injection`] holds the `extraConfig` injection sources and the rule that +//! picks between an injected value and the top-level field it stands in for, +//! shared across eras. /// Alonzo-era genesis parameters (cost models, prices, max collateral). pub mod alonzo; @@ -37,5 +40,7 @@ pub mod byron; pub mod conway; /// Built-in Plutus V1/V2/V3 cost-model snapshots. pub mod cost_models; +/// Genesis `extraConfig` injection sources, shared across eras. +pub mod injection; /// Shelley-era genesis configuration (network start, system start, k, …). pub mod shelley; diff --git a/pallas-configs/src/shelley.rs b/pallas-configs/src/shelley.rs index 8464de54..f9dae913 100644 --- a/pallas-configs/src/shelley.rs +++ b/pallas-configs/src/shelley.rs @@ -1,3 +1,4 @@ +use crate::injection::{self, Injection}; use num_rational::Rational64; use pallas_crypto::hash::Hash; use pallas_primitives::conway::{Epoch, RationalNumber}; @@ -157,21 +158,71 @@ pub struct RewardAccount { pub network: String, } -#[derive(Debug, Deserialize, Clone)] +/// A pool entry as written, before the current and legacy names for its +/// id and reward account are reconciled. +#[derive(Deserialize)] #[serde(rename_all = "camelCase")] +struct PoolRaw { + cost: u64, + #[serde(deserialize_with = "deserialize_rational")] + margin: pallas_primitives::alonzo::RationalNumber, + metadata: Option, + #[serde(default)] + owners: Vec, + pledge: u64, + pool_id: Option, + public_key: Option, + relays: Vec>, + account_address: Option, + reward_account: Option, + vrf: String, + #[serde(default)] + registration_deposit: Option, +} + +impl TryFrom for Pool { + type Error = String; + + fn try_from(raw: PoolRaw) -> Result { + // The ledger accepts either name and prefers `poolId`. A serde alias + // would reject a pool carrying both as a duplicate field, so the + // fallback is written out by hand. + let public_key = raw.pool_id.or(raw.public_key).ok_or_else(|| { + "a pool names its identifier neither as poolId nor as publicKey".to_string() + })?; + + let reward_account = raw.account_address.or(raw.reward_account).ok_or_else(|| { + "a pool names its reward account neither as accountAddress nor as rewardAccount" + .to_string() + })?; + + Ok(Self { + cost: raw.cost, + margin: raw.margin, + metadata: raw.metadata, + owners: raw.owners, + pledge: raw.pledge, + public_key, + relays: raw.relays, + reward_account, + vrf: raw.vrf, + registration_deposit: raw.registration_deposit, + }) + } +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(try_from = "PoolRaw")] pub struct Pool { pub cost: u64, - #[serde(deserialize_with = "deserialize_rational")] pub margin: pallas_primitives::alonzo::RationalNumber, pub metadata: Option, - #[serde(default)] pub owners: Vec, pub pledge: u64, - pub public_key: String, // pool ID + pub public_key: String, // pool ID, written as `poolId` pub relays: Vec>, - pub reward_account: RewardAccount, + pub reward_account: RewardAccount, // written as `accountAddress` pub vrf: String, - #[serde(default)] pub registration_deposit: Option, } @@ -182,8 +233,115 @@ pub struct Staking { pub stake: Option>, } -#[derive(Debug, Deserialize, Clone)] +/// The Shelley genesis fields a generator writes under `extraConfig` rather +/// than at the top level. +#[derive(Debug, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExtraConfig { + pub initial_funds: Option>>, + pub stake_pools: Option>>, + pub stake_credentials: Option>>, +} + +#[derive(Deserialize)] #[serde(rename_all = "camelCase")] +struct GenesisFileRaw { + active_slots_coeff: Option, + epoch_length: Option, + gen_delegs: Option>, + initial_funds: Option>, + max_lovelace_supply: Option, + network_id: Option, + network_magic: Option, + protocol_params: ProtocolParams, + security_param: Option, + slot_length: Option, + staking: Option, + system_start: Option, + update_quorum: Option, + extra_config: Option, + + #[serde(rename = "maxKESEvolutions")] + max_kes_evolutions: Option, + + #[serde(rename = "slotsPerKESPeriod")] + slots_per_kes_period: Option, +} + +impl TryFrom for GenesisFile { + type Error = String; + + fn try_from(raw: GenesisFileRaw) -> Result { + raw.fold(&injection::Source::NoFilesystem) + } +} + +impl GenesisFileRaw { + fn fold(self, source: &injection::Source) -> Result { + let extra = self.extra_config.unwrap_or_default(); + + let had_staking = self.staking.is_some(); + let (top_level_pools, top_level_stake) = match self.staking { + Some(staking) => (staking.pools, staking.stake), + None => (None, None), + }; + + let initial_funds = injection::resolve( + source, + "initialFunds", + "initialFunds", + extra.initial_funds, + self.initial_funds, + )?; + + let pools = injection::resolve( + source, + "stakePools", + "staking.pools", + extra.stake_pools, + top_level_pools, + )?; + + let stake = injection::resolve( + source, + "stakeCredentials", + "staking.stake", + extra.stake_credentials, + top_level_stake, + )?; + + let staking = if had_staking || pools.is_some() || stake.is_some() { + Some(Staking { pools, stake }) + } else { + None + }; + + Ok(GenesisFile { + active_slots_coeff: self.active_slots_coeff, + epoch_length: self.epoch_length, + gen_delegs: self.gen_delegs, + initial_funds, + max_lovelace_supply: self.max_lovelace_supply, + network_id: self.network_id, + network_magic: self.network_magic, + protocol_params: self.protocol_params, + security_param: self.security_param, + slot_length: self.slot_length, + staking, + system_start: self.system_start, + update_quorum: self.update_quorum, + max_kes_evolutions: self.max_kes_evolutions, + slots_per_kes_period: self.slots_per_kes_period, + }) + } +} + +/// A parsed Shelley genesis file. +/// +/// A parsed shelley genesis. Funds, pools and delegations written under +/// `extraConfig` are already merged into `initial_funds` and `staking`. +#[derive(Debug, Deserialize, Clone)] +#[serde(try_from = "GenesisFileRaw")] pub struct GenesisFile { pub active_slots_coeff: Option, pub epoch_length: Option, @@ -198,20 +356,22 @@ pub struct GenesisFile { pub staking: Option, pub system_start: Option, pub update_quorum: Option, - - #[serde(rename = "maxKESEvolutions")] pub max_kes_evolutions: Option, - - #[serde(rename = "slotsPerKESPeriod")] pub slots_per_kes_period: Option, } pub fn from_file(path: &std::path::Path) -> Result { - let file = std::fs::File::open(path)?; - let reader = std::io::BufReader::new(file); - let parsed: GenesisFile = serde_json::from_reader(reader)?; + let text = std::fs::read_to_string(path)?; + let raw: GenesisFileRaw = serde_json::from_str(&text)?; - Ok(parsed) + // The segments are joined under the directory of the genesis file. + let directory = path + .parent() + .unwrap_or_else(|| std::path::Path::new("")) + .to_path_buf(); + + raw.fold(&injection::Source::Directory(directory)) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err)) } pub type GenesisUtxo = (Hash<32>, pallas_addresses::Address, u64); @@ -236,13 +396,79 @@ pub fn shelley_utxos(config: &GenesisFile) -> Vec { mod tests { use super::*; - fn load_test_data_config(network: &str) -> GenesisFile { - let path = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) + const INJECTED_POOL: &str = "9c70bd513f16961b3debef699da1fb8c77138edd149f00f9b3b5522d"; + const INJECTED_DELEGATOR: &str = "ae1537988b77a8815a7502eb6e02026bc71d0a7729cddc2a21582980"; + const INJECTED_VRF: &str = "63986178c45c411fb4ab0fe4f8717a102168ceb6727bcfec68edf577c08349bd"; + const INJECTED_CREDENTIAL: &str = "8c2bc8429a2fb885c1b17a8095b927cfcfe97e4fa5fe83f0dd4418ab"; + + fn test_data_path(network: &str) -> std::path::PathBuf { + std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) .join("..") .join("test_data") - .join(format!("{network}-shelley-genesis.json")); + .join(format!("{network}-shelley-genesis.json")) + } + + fn load_test_data_config(network: &str) -> GenesisFile { + from_file(&test_data_path(network)).unwrap() + } + + fn test_data_json(network: &str) -> serde_json::Value { + let text = std::fs::read_to_string(test_data_path(network)).unwrap(); + + serde_json::from_str(&text).unwrap() + } + + fn injected_funds() -> HashMap { + [ + ( + "00fbd8c60336e0f03852209dcb827dd30e789ee80e6d104369097a3cb2ae1537988b77a8815a7502eb6e02026bc71d0a7729cddc2a21582980", + 4_500_000_000_000, + ), + ( + "6004d2cf712cfcaafb8bda85dc31baf3a35168d2e28029e0b56c562d37", + 2_250_000_000_000, + ), + ( + "607d1ca70e7e84c99e23c3f9494bc48a9fe30e98e02e25745f96d7a731", + 2_250_000_000_000, + ), + ] + .into_iter() + .map(|(address, amount)| (address.to_string(), amount)) + .collect() + } - from_file(&path).unwrap() + fn sorted_keys(value: &serde_json::Value) -> Vec { + let mut keys: Vec = value + .as_object() + .expect("expected a JSON object") + .keys() + .cloned() + .collect(); + keys.sort(); + + keys + } + + fn pool_json(id_key: &str, account_key: &str) -> String { + format!( + r#"{{ + "{id_key}": "{INJECTED_POOL}", + "{account_key}": {{ + "credential": {{ + "keyHash": "{INJECTED_CREDENTIAL}" + }}, + "network": "Testnet" + }}, + "cost": 0, + "margin": 0, + "metadata": null, + "owners": [], + "pledge": 0, + "relays": [], + "vrf": "{INJECTED_VRF}" + }}"# + ) } #[test] @@ -271,6 +497,16 @@ mod tests { load_test_data_config("mainnet"); } + #[test] + fn test_musashi_json_loads() { + let json = test_data_json("musashi"); + assert!(json["initialFunds"].as_object().unwrap().is_empty()); + + let config = load_test_data_config("musashi"); + let total: u64 = shelley_utxos(&config).iter().map(|(_, _, v)| v).sum(); + assert_eq!(total, 30000000900000000); + } + #[test] fn test_partner_staking_parses() { let config = load_test_data_config("partner"); @@ -317,4 +553,432 @@ mod tests { "2d0de269b0996fdcd8f19f0b6d7d0bf14363984482f181a5a1ccd036" ); } + + #[test] + fn injected_funds_reach_the_utxo_set() { + let value = test_data_json("generated"); + + assert!( + value["initialFunds"] + .as_object() + .expect("the fixture must carry a top level initialFunds object") + .is_empty(), + "the fixture's top level funds must be empty, or this says nothing about the injection" + ); + + let injected: HashMap = + serde_json::from_value(value["extraConfig"]["initialFunds"]["data"].clone()) + .expect("the fixture must carry an injected fund payload"); + + assert_eq!( + injected, + injected_funds(), + "the fixture must inject the funds this case was written against" + ); + + let config = load_test_data_config("generated"); + + assert_eq!( + config.initial_funds.as_ref(), + Some(&injected), + "the folded funds must be exactly the injected payload" + ); + + let reached: HashMap = shelley_utxos(&config) + .into_iter() + .map(|(_, address, amount)| (address.to_hex(), amount)) + .collect(); + + assert_eq!( + reached, injected, + "every injected fund must reach the utxo set under its own address" + ); + } + + #[test] + fn injected_staking_reaches_the_accessor() { + let value = test_data_json("generated"); + + for field in ["pools", "stake"] { + assert!( + value["staking"][field] + .as_object() + .unwrap_or_else(|| panic!("the fixture must carry a top level staking.{field}")) + .is_empty(), + "the fixture's top level staking.{field} must be empty, or this says nothing about the injection" + ); + } + + let injected_pools: Vec = sorted_keys(&value["extraConfig"]["stakePools"]["data"]); + let injected_stake: HashMap = + serde_json::from_value(value["extraConfig"]["stakeCredentials"]["data"].clone()) + .expect("the fixture must carry an injected credential payload"); + + assert_eq!( + injected_pools, + vec![INJECTED_POOL.to_string()], + "the fixture must inject the pool this case was written against" + ); + assert_eq!( + injected_stake.get(INJECTED_DELEGATOR), + Some(&INJECTED_POOL.to_string()), + "the fixture must inject the delegation this case was written against" + ); + + let config = load_test_data_config("generated"); + + let staking = config.staking.expect("staking must be present"); + let pools = staking.pools.expect("pools must be present"); + let stake = staking.stake.expect("delegations must be present"); + + let mut reached: Vec = pools.keys().cloned().collect(); + reached.sort(); + + assert_eq!( + reached, injected_pools, + "the folded pools must be exactly the injected ones" + ); + assert_eq!( + stake, injected_stake, + "the folded delegations must be exactly the injected payload" + ); + + let pool = pools + .get(INJECTED_POOL) + .expect("the injected pool must be present"); + + assert_eq!(pool.public_key, INJECTED_POOL); + assert_eq!(pool.vrf, INJECTED_VRF); + assert_eq!(pool.reward_account.network, "Testnet"); + + match &pool.reward_account.credential { + Credential::KeyHash(key) => assert_eq!(key, INJECTED_CREDENTIAL), + _ => panic!("expected a key hash credential"), + } + } + + #[test] + fn an_injection_file_reaches_the_utxo_set() { + let value = test_data_json("file-injection"); + + assert_eq!( + value["extraConfig"]["initialFunds"]["file"], + serde_json::json!(["file-injection", "initial-funds.json"]), + "the fixture must name the injection file this case was written against" + ); + assert!( + value["initialFunds"] + .as_object() + .expect("the fixture must carry a top level initialFunds object") + .is_empty(), + "the fixture's top level funds must be empty, or this says nothing about the injection" + ); + + let config = load_test_data_config("file-injection"); + + assert_eq!( + config.initial_funds.as_ref(), + Some(&injected_funds()), + "the funds read from the injection file must be the inline fixture's funds" + ); + + let reached: HashMap = shelley_utxos(&config) + .into_iter() + .map(|(_, address, amount)| (address.to_hex(), amount)) + .collect(); + + assert_eq!( + reached, + injected_funds(), + "every fund read from the file must reach the utxo set under its own address" + ); + } + + #[test] + fn the_parse_path_still_refuses_an_injection_file() { + let text = std::fs::read_to_string(test_data_path("file-injection")).unwrap(); + + let err = serde_json::from_str::(&text) + .expect_err("a genesis parsed from text alone must refuse an injection file"); + + assert!(err.to_string().contains("initialFunds"), "{err}"); + assert!( + err.to_string() + .contains("file-injection/initial-funds.json"), + "{err}" + ); + assert!( + err.to_string().contains("cannot be read while parsing"), + "{err}" + ); + } + + #[test] + fn an_empty_payload_against_a_populated_top_level_is_refused() { + let mut value = test_data_json("generated"); + let funds = value["extraConfig"]["initialFunds"]["data"].take(); + value["initialFunds"] = funds; + value["extraConfig"]["initialFunds"] = serde_json::json!({ "data": {} }); + + let err = serde_json::from_value::(value) + .expect_err("an empty payload against a populated top level must be refused"); + + assert!(err.to_string().contains("both populated"), "{err}"); + } + + #[test] + fn an_unmodelled_extra_config_key_is_refused() { + let mut value = test_data_json("generated"); + value["extraConfig"]["initialDReps"] = serde_json::json!({ "data": {} }); + + let err = serde_json::from_value::(value) + .expect_err("an unmodelled extraConfig key must be refused"); + + assert!(err.to_string().contains("unknown field"), "{err}"); + assert!(err.to_string().contains("initialDReps"), "{err}"); + } + + #[test] + fn a_pool_parses_under_the_current_names() { + let pool: Pool = + serde_json::from_str(&pool_json("poolId", "accountAddress")).expect("pool must parse"); + + assert_eq!(pool.public_key, INJECTED_POOL); + assert_eq!(pool.reward_account.network, "Testnet"); + } + + #[test] + fn a_pool_parses_under_the_older_names() { + let pool: Pool = serde_json::from_str(&pool_json("publicKey", "rewardAccount")) + .expect("pool must parse"); + + assert_eq!(pool.public_key, INJECTED_POOL); + assert_eq!(pool.reward_account.network, "Testnet"); + } + + #[test] + fn a_field_named_neither_way_is_refused() { + let err = serde_json::from_str::(&pool_json("poolIdentifier", "accountAddress")) + .expect_err("a pool with no identifier must be refused"); + + assert!(err.to_string().contains("poolId"), "{err}"); + assert!(err.to_string().contains("publicKey"), "{err}"); + + let err = serde_json::from_str::(&pool_json("poolId", "account")) + .expect_err("a pool with no reward account must be refused"); + + assert!(err.to_string().contains("accountAddress"), "{err}"); + assert!(err.to_string().contains("rewardAccount"), "{err}"); + } + + // The two values differ on purpose, so the assertion says which spelling + // won rather than only that one did. + #[test] + fn both_spellings_prefer_the_current_name() { + let mut value: serde_json::Value = + serde_json::from_str(&pool_json("poolId", "accountAddress")).unwrap(); + value["publicKey"] = + serde_json::json!("0000000000000000000000000000000000000000000000000000000f"); + value["rewardAccount"] = serde_json::json!({ + "credential": { + "keyHash": "00000000000000000000000000000000000000000000000000000001" + }, + "network": "Mainnet" + }); + + let pool: Pool = + serde_json::from_value(value).expect("a pool with both spellings must parse"); + + assert_eq!(pool.public_key, INJECTED_POOL); + assert_eq!(pool.reward_account.network, "Testnet"); + + match &pool.reward_account.credential { + Credential::KeyHash(key) => assert_eq!(key, INJECTED_CREDENTIAL), + _ => panic!("expected a key hash credential"), + } + } + + // The counts are named as well as the maps, because a comparison drawn + // entirely from the file moves whenever the file moves. + #[test] + fn a_genesis_without_extra_config_is_untouched() { + for (network, fund_count, pool_count) in [("golden", 1, 1), ("partner", 4, 3)] { + let value = test_data_json(network); + + assert!( + value.get("extraConfig").is_none(), + "{network} must carry no extraConfig, or it is the wrong fixture for this case" + ); + + let funds: HashMap = serde_json::from_value(value["initialFunds"].clone()) + .unwrap_or_else(|err| panic!("{network} initialFunds must parse: {err}")); + + assert_eq!(funds.len(), fund_count, "{network} top level fund count"); + + let config = load_test_data_config(network); + + assert_eq!( + config.initial_funds.as_ref(), + Some(&funds), + "{network} funds must be exactly its own top level map" + ); + + let pools = sorted_keys(&value["staking"]["pools"]); + assert_eq!(pools.len(), pool_count, "{network} top level pool count"); + + let staking = config + .staking + .unwrap_or_else(|| panic!("{network} staking must be present")); + let mut reached: Vec = staking + .pools + .unwrap_or_else(|| panic!("{network} pools must be present")) + .keys() + .cloned() + .collect(); + reached.sort(); + + assert_eq!( + reached, pools, + "{network} pools must be exactly its own top level map" + ); + } + } + + #[test] + fn an_absent_injection_leaves_the_top_level() { + let mut value = test_data_json("generated"); + let funds = value["extraConfig"]["initialFunds"]["data"].take(); + value["initialFunds"] = funds; + value["extraConfig"]["initialFunds"] = serde_json::json!({}); + + let config: GenesisFile = + serde_json::from_value(value).expect("the genesis must still parse"); + + assert_eq!(shelley_utxos(&config).len(), 3); + } + + // The top-level pool is the injected one under a different identifier and + // in the older spelling, so only the two populated maps carry the refusal. + #[test] + fn pools_in_both_places_are_refused() { + let mut value = test_data_json("generated"); + let mut pool = value["extraConfig"]["stakePools"]["data"][INJECTED_POOL].clone(); + + let entry = pool.as_object_mut().expect("the pool must be an object"); + let id = entry.remove("poolId").expect("the pool must carry an id"); + entry.insert("publicKey".to_string(), id); + let account = entry + .remove("accountAddress") + .expect("the pool must carry an account"); + entry.insert("rewardAccount".to_string(), account); + + value["staking"]["pools"] = serde_json::json!({ + "0000000000000000000000000000000000000000000000000000000f": pool + }); + + let err = serde_json::from_value::(value) + .expect_err("a pool map with two sources must be refused"); + + assert!(err.to_string().contains("stakePools"), "{err}"); + assert!(err.to_string().contains("both populated"), "{err}"); + } + + #[test] + fn funds_in_both_places_are_refused() { + let mut value = test_data_json("generated"); + value["initialFunds"] = serde_json::json!({ + "6000000000000000000000000000000000000000000000000000000001": 1 + }); + + let err = serde_json::from_value::(value) + .expect_err("funds with two sources must be refused"); + + assert!(err.to_string().contains("initialFunds"), "{err}"); + assert!(err.to_string().contains("both populated"), "{err}"); + } + + #[test] + fn credentials_in_both_places_are_refused() { + let mut value = test_data_json("generated"); + value["staking"]["stake"] = serde_json::json!({ + "0000000000000000000000000000000000000000000000000000000e": + "0000000000000000000000000000000000000000000000000000000f" + }); + + let err = serde_json::from_value::(value) + .expect_err("stake credentials with two sources must be refused"); + + assert!(err.to_string().contains("stakeCredentials"), "{err}"); + assert!(err.to_string().contains("both populated"), "{err}"); + } + + #[test] + fn a_file_arm_stops_the_whole_genesis() { + let mut value = test_data_json("generated"); + value["extraConfig"]["initialFunds"] = serde_json::json!({ + "file": ["genesis", "funds.json"], + "hash": "abcd" + }); + + let err = serde_json::from_value::(value) + .expect_err("a genesis naming an injection file must be refused"); + + assert!(err.to_string().contains("initialFunds"), "{err}"); + assert!(err.to_string().contains("genesis/funds.json"), "{err}"); + } + + #[test] + fn injected_staking_needs_no_staking_section() { + let mut value = test_data_json("generated"); + value + .as_object_mut() + .expect("the genesis must be an object") + .remove("staking") + .expect("the fixture must carry a staking section"); + + let config: GenesisFile = serde_json::from_value(value).expect("the genesis must parse"); + + let staking = config + .staking + .expect("injected staking must reach the accessor with no staking section"); + + assert_eq!(staking.pools.expect("pools must be present").len(), 1); + assert_eq!(staking.stake.expect("delegations must be present").len(), 1); + } + + #[test] + fn no_staking_anywhere_means_none() { + let mut value = test_data_json("generated"); + let object = value + .as_object_mut() + .expect("the genesis must be an object"); + object.remove("staking"); + object.remove("extraConfig"); + + let config: GenesisFile = serde_json::from_value(value).expect("the genesis must parse"); + + assert!( + config.staking.is_none(), + "a genesis naming no staking anywhere must have none" + ); + } + + #[test] + fn an_empty_staking_section_survives() { + let mut value = test_data_json("generated"); + value["staking"] = serde_json::json!({}); + value + .as_object_mut() + .expect("the genesis must be an object") + .remove("extraConfig"); + + let config: GenesisFile = serde_json::from_value(value).expect("the genesis must parse"); + + let staking = config + .staking + .expect("an empty staking section must survive the fold"); + + assert!(staking.pools.is_none(), "no pools key means no pools"); + assert!(staking.stake.is_none(), "no stake key means no stake"); + } } diff --git a/test_data/file-injection-shelley-genesis.json b/test_data/file-injection-shelley-genesis.json new file mode 100644 index 00000000..6be56e82 --- /dev/null +++ b/test_data/file-injection-shelley-genesis.json @@ -0,0 +1,91 @@ +{ + "activeSlotsCoeff": 5.0e-2, + "epochLength": 432000, + "extraConfig": { + "initialFunds": { + "file": [ + "file-injection", + "initial-funds.json" + ], + "hash": "5f5ef4cb568ce42c470afcf6bfbca574ae345e1323b26be0de40d471db835ad7" + }, + "stakeCredentials": { + "data": { + "ae1537988b77a8815a7502eb6e02026bc71d0a7729cddc2a21582980": "9c70bd513f16961b3debef699da1fb8c77138edd149f00f9b3b5522d" + } + }, + "stakePools": { + "data": { + "9c70bd513f16961b3debef699da1fb8c77138edd149f00f9b3b5522d": { + "accountAddress": { + "credential": { + "keyHash": "8c2bc8429a2fb885c1b17a8095b927cfcfe97e4fa5fe83f0dd4418ab" + }, + "network": "Testnet" + }, + "cost": 0, + "leiosKey": null, + "margin": 0, + "metadata": null, + "owners": [], + "pledge": 0, + "poolId": "9c70bd513f16961b3debef699da1fb8c77138edd149f00f9b3b5522d", + "relays": [], + "vrf": "63986178c45c411fb4ab0fe4f8717a102168ceb6727bcfec68edf577c08349bd" + } + } + } + }, + "genDelegs": { + "85c1b9ba5adb577fe9b88da5b32dd47c072be15b492f561e0a479e04": { + "delegate": "7a1fbbee51d84468c3a3bab082bd8997bdad255847a85b6cb166fab1", + "vrf": "91f2e5e63bb3c665c419434966a2a53c236b114241850d036bbfeb6d04908ab2" + }, + "a1b7e719d695d9a38ea21989bc156d9d2a78782b48757ea4ff67fd22": { + "delegate": "62cec32ac154de5d116e014d68c309da6dc9a3b69c9065117b5b8128", + "vrf": "2864de01f731e263005bee946672335e9a9896c9f8e652e4bc0a6d35f6ee096e" + }, + "c337c88dc43204751b8a9de1df3969180e9428b44cd31f9a10b3aa4f": { + "delegate": "9e58d35c4fcca5963c5aaf114c1a40ef48c4732a3c2a98f59b983768", + "vrf": "77d52981ec98db03064656145686af4284b6875abfe75733b2b65ee5a25311a6" + } + }, + "initialFunds": {}, + "maxKESEvolutions": 60, + "maxLovelaceSupply": 10000000000000, + "networkId": "Testnet", + "networkMagic": 42, + "protocolParams": { + "a0": 0, + "decentralisationParam": 1, + "eMax": 18, + "extraEntropy": { + "tag": "NeutralNonce" + }, + "keyDeposit": 400000, + "maxBlockBodySize": 65536, + "maxBlockHeaderSize": 1100, + "maxTxSize": 16384, + "minFeeA": 1, + "minFeeB": 0, + "minPoolCost": 0, + "minUTxOValue": 0, + "nOpt": 100, + "poolDeposit": 0, + "protocolVersion": { + "major": 2, + "minor": 0 + }, + "rho": 0.1, + "tau": 0.1 + }, + "securityParam": 2160, + "slotLength": 1, + "slotsPerKESPeriod": 129600, + "staking": { + "pools": {}, + "stake": {} + }, + "systemStart": "2026-09-08T22:41:07.04987665Z", + "updateQuorum": 5 +} diff --git a/test_data/file-injection/initial-funds.json b/test_data/file-injection/initial-funds.json new file mode 100644 index 00000000..40af1cc3 --- /dev/null +++ b/test_data/file-injection/initial-funds.json @@ -0,0 +1,5 @@ +{ + "00fbd8c60336e0f03852209dcb827dd30e789ee80e6d104369097a3cb2ae1537988b77a8815a7502eb6e02026bc71d0a7729cddc2a21582980": 4500000000000, + "6004d2cf712cfcaafb8bda85dc31baf3a35168d2e28029e0b56c562d37": 2250000000000, + "607d1ca70e7e84c99e23c3f9494bc48a9fe30e98e02e25745f96d7a731": 2250000000000 +} diff --git a/test_data/generated-shelley-genesis.json b/test_data/generated-shelley-genesis.json new file mode 100644 index 00000000..4b0dd0c1 --- /dev/null +++ b/test_data/generated-shelley-genesis.json @@ -0,0 +1,91 @@ +{ + "activeSlotsCoeff": 5.0e-2, + "epochLength": 432000, + "extraConfig": { + "initialFunds": { + "data": { + "00fbd8c60336e0f03852209dcb827dd30e789ee80e6d104369097a3cb2ae1537988b77a8815a7502eb6e02026bc71d0a7729cddc2a21582980": 4500000000000, + "6004d2cf712cfcaafb8bda85dc31baf3a35168d2e28029e0b56c562d37": 2250000000000, + "607d1ca70e7e84c99e23c3f9494bc48a9fe30e98e02e25745f96d7a731": 2250000000000 + } + }, + "stakeCredentials": { + "data": { + "ae1537988b77a8815a7502eb6e02026bc71d0a7729cddc2a21582980": "9c70bd513f16961b3debef699da1fb8c77138edd149f00f9b3b5522d" + } + }, + "stakePools": { + "data": { + "9c70bd513f16961b3debef699da1fb8c77138edd149f00f9b3b5522d": { + "accountAddress": { + "credential": { + "keyHash": "8c2bc8429a2fb885c1b17a8095b927cfcfe97e4fa5fe83f0dd4418ab" + }, + "network": "Testnet" + }, + "cost": 0, + "leiosKey": null, + "margin": 0, + "metadata": null, + "owners": [], + "pledge": 0, + "poolId": "9c70bd513f16961b3debef699da1fb8c77138edd149f00f9b3b5522d", + "relays": [], + "vrf": "63986178c45c411fb4ab0fe4f8717a102168ceb6727bcfec68edf577c08349bd" + } + } + } + }, + "genDelegs": { + "85c1b9ba5adb577fe9b88da5b32dd47c072be15b492f561e0a479e04": { + "delegate": "7a1fbbee51d84468c3a3bab082bd8997bdad255847a85b6cb166fab1", + "vrf": "91f2e5e63bb3c665c419434966a2a53c236b114241850d036bbfeb6d04908ab2" + }, + "a1b7e719d695d9a38ea21989bc156d9d2a78782b48757ea4ff67fd22": { + "delegate": "62cec32ac154de5d116e014d68c309da6dc9a3b69c9065117b5b8128", + "vrf": "2864de01f731e263005bee946672335e9a9896c9f8e652e4bc0a6d35f6ee096e" + }, + "c337c88dc43204751b8a9de1df3969180e9428b44cd31f9a10b3aa4f": { + "delegate": "9e58d35c4fcca5963c5aaf114c1a40ef48c4732a3c2a98f59b983768", + "vrf": "77d52981ec98db03064656145686af4284b6875abfe75733b2b65ee5a25311a6" + } + }, + "initialFunds": {}, + "maxKESEvolutions": 60, + "maxLovelaceSupply": 10000000000000, + "networkId": "Testnet", + "networkMagic": 42, + "protocolParams": { + "a0": 0, + "decentralisationParam": 1, + "eMax": 18, + "extraEntropy": { + "tag": "NeutralNonce" + }, + "keyDeposit": 400000, + "maxBlockBodySize": 65536, + "maxBlockHeaderSize": 1100, + "maxTxSize": 16384, + "minFeeA": 1, + "minFeeB": 0, + "minPoolCost": 0, + "minUTxOValue": 0, + "nOpt": 100, + "poolDeposit": 0, + "protocolVersion": { + "major": 2, + "minor": 0 + }, + "rho": 0.1, + "tau": 0.1 + }, + "securityParam": 2160, + "slotLength": 1, + "slotsPerKESPeriod": 129600, + "staking": { + "pools": {}, + "stake": {} + }, + "systemStart": "2026-09-08T22:41:07.04987665Z", + "updateQuorum": 5 +} \ No newline at end of file diff --git a/test_data/musashi-shelley-genesis.json b/test_data/musashi-shelley-genesis.json new file mode 100644 index 00000000..0967aab1 --- /dev/null +++ b/test_data/musashi-shelley-genesis.json @@ -0,0 +1,90 @@ +{ + "activeSlotsCoeff": 0.050, + "epochLength": 21600, + "extraConfig": { + "initialFunds": { + "data": { + "0066878003c80c68e610236f87f742f06c53f30e4b051720faeff49f855e81366cb6f3c0d14837614afcea669d51b8be9519eaec4a237504f8": 900000000, + "60318277a23738a860d7b8f727b113236b2cd11cc413a5db0938241a27": 30000000000000000 + } + }, + "stakeCredentials": { + "data": { + "5e81366cb6f3c0d14837614afcea669d51b8be9519eaec4a237504f8": "747aca09f322d2dfc56243b839e2d573ab92287684e5e37d66ec0f87" + } + }, + "stakePools": { + "data": { + "747aca09f322d2dfc56243b839e2d573ab92287684e5e37d66ec0f87": { + "accountAddress": { + "credential": { + "keyHash": "bc2b888f42c68e4e118a4fa16ada52db896bffb61ceea51a58301bf6" + }, + "network": "Testnet" + }, + "cost": 0, + "leiosKey": null, + "margin": 0, + "metadata": null, + "owners": [], + "pledge": 0, + "poolId": "747aca09f322d2dfc56243b839e2d573ab92287684e5e37d66ec0f87", + "relays": [], + "vrf": "d8252bd637a90ba4dbd2cf63afda20a19888b7895ede067081ce7fb7411a972b" + } + } + } + }, + "genDelegs": { + "12d894311704be0bcbbf3c802d1df931c1042f165118b5950c38c4fb": { + "delegate": "28e61504346b2321a7474e84bb868d20282ecca551fa195b474134cd", + "vrf": "815dfe31669dec6dae261a0c38009d6c195cb746b6f1053a12bb29fd585228bd" + }, + "293a1d21af16b2a6c4553e5a587c6518c7ed13aecf6d467a276c5f5b": { + "delegate": "aa43125599455749e209b6ac89f87f4b2b7829b951b278914ff41da7", + "vrf": "ea3081e44ca2429c22bc1f8e09756a722ca76d9f89a1a637bac363d2d5fddefe" + }, + "aa57d73d4b8790ac4fd195c2cfb0aefb82abb828893f4203e5eaf223": { + "delegate": "3c633d470ebb66763f598b92366c034daf87bd32f5619d9be7421281", + "vrf": "e431849e1f74782bde85e4030abebfbae6a5cd6c18c20ca35068d1c449287064" + } + }, + "initialFunds": {}, + "maxKESEvolutions": 62, + "maxLovelaceSupply": 45000000000000000, + "networkId": "Testnet", + "networkMagic": 164, + "protocolParams": { + "a0": 0.3, + "decentralisationParam": 1, + "eMax": 18, + "extraEntropy": { + "tag": "NeutralNonce" + }, + "keyDeposit": 2000000, + "maxBlockBodySize": 90112, + "maxBlockHeaderSize": 1100, + "maxTxSize": 16384, + "minFeeA": 44, + "minFeeB": 155381, + "minPoolCost": 170000000, + "minUTxOValue": 1000000, + "nOpt": 150, + "poolDeposit": 500000000, + "protocolVersion": { + "major": 11, + "minor": 0 + }, + "rho": 0.0030, + "tau": 0.2 + }, + "securityParam": 108, + "slotLength": 1, + "slotsPerKESPeriod": 129600, + "staking": { + "pools": {}, + "stake": {} + }, + "systemStart": "2026-08-07T00:00:00Z", + "updateQuorum": 3 +}