montana/Montana-Protocol/Code/crates/mt-genesis/src/manifest.rs

247 lines
9.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// spec, раздел "Сетевой уровень → Genesis manifest" (M8 cross-machine peer discovery)
//
// GenesisManifest — детерминированный список known peers для генезис-cohort.
// Каждый узел при `montana-node start --genesis-manifest <path>` читает manifest,
// dial-ит peers из списка, верифицирует libp2p PeerId совпадает с pinned значением.
//
// **NOT genesis_state_hash binding.** Manifest — операционные метаданные network (JSON формат)
// (multiaddr, peer_id), не входит в Genesis Decree (`ProtocolParams`). Изменение
// IP / port / replacement узла не требует ceremony — только переиздание manifest-а.
//
// Genesis Decree (`ProtocolParams::bootstrap_account_pubkey` /
// `bootstrap_node_pubkey` / `target_zero` / `genesis_content_data_hash`) — это
// immutable consensus binding, фиксируется ceremony-ой и попадает в
// `compute_genesis_state_hash()`.
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
pub struct GenesisPeer {
/// Человекочитаемая метка («moscow», «frankfurt», «helsinki»).
pub label: String,
/// libp2p multiaddr вида `/ip4/<addr>/tcp/<port>`. Без `/p2p/<peer_id>`
/// suffix — peer_id хранится отдельно в поле `peer_id` для явности.
pub multiaddr: String,
/// libp2p PeerId в multihash base58 представлении (например `12D3KooW...`).
/// Pinned при загрузке manifest-а — connection rejected если actual peer_id
/// не совпадает.
pub peer_id: String,
/// account_id (32 байта SHA-256 от account_pk) в lowercase hex 64 символа.
pub account_id_hex: String,
/// node_id (32 байта SHA-256 от node_pk) в lowercase hex 64 символа.
pub node_id_hex: String,
/// `true` если этот узел = bootstrap (operator с эмиссией Day 1, его
/// account_pk + node_pk финализированы в `ProtocolParams`).
/// Среди peers в manifest-е может быть **ровно один** bootstrap.
#[serde(default)]
pub bootstrap: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
pub struct GenesisManifest {
/// Имя сети для UX (mainnet всегда `"montana"`, тестнеты — описательно).
pub network_name: String,
/// Список генезис-cohort peers. Минимум 1 (singleton + ceremony deferred),
/// типично 3 (initial Active + 2 candidates для М8 ceremony).
pub peers: Vec<GenesisPeer>,
}
#[derive(Debug)]
pub enum ManifestError {
Json(serde_json::Error),
NoBootstrap,
MultipleBootstrap(usize),
EmptyPeers,
InvalidHexLength {
field: &'static str,
expected: usize,
actual: usize,
},
}
impl std::fmt::Display for ManifestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Json(e) => write!(f, "ошибка JSON: {e}"),
Self::NoBootstrap => {
write!(f, "manifest не содержит ни одного bootstrap = true peer")
},
Self::MultipleBootstrap(n) => {
write!(f, "manifest содержит {n} bootstrap peers, ожидался ровно 1")
},
Self::EmptyPeers => write!(f, "manifest содержит 0 peers, минимум 1"),
Self::InvalidHexLength {
field,
expected,
actual,
} => write!(
f,
"поле {field}: ожидалось {expected} hex-символов, получили {actual}"
),
}
}
}
impl std::error::Error for ManifestError {}
impl GenesisManifest {
/// Парсит TOML-текст и валидирует invariants:
/// - peers непустой
/// - ровно один peer с `bootstrap = true`
/// - account_id_hex / node_id_hex длиной 64 символа каждый
pub fn parse(json_text: &str) -> Result<Self, ManifestError> {
let manifest: GenesisManifest =
serde_json::from_str(json_text).map_err(ManifestError::Json)?;
manifest.validate()?;
Ok(manifest)
}
pub fn to_json_string(&self) -> Result<String, ManifestError> {
serde_json::to_string_pretty(self).map_err(ManifestError::Json)
}
pub fn validate(&self) -> Result<(), ManifestError> {
if self.peers.is_empty() {
return Err(ManifestError::EmptyPeers);
}
let bootstrap_count = self.peers.iter().filter(|p| p.bootstrap).count();
match bootstrap_count {
0 => return Err(ManifestError::NoBootstrap),
1 => (),
n => return Err(ManifestError::MultipleBootstrap(n)),
}
for peer in &self.peers {
if peer.account_id_hex.len() != 64 {
return Err(ManifestError::InvalidHexLength {
field: "account_id_hex",
expected: 64,
actual: peer.account_id_hex.len(),
});
}
if peer.node_id_hex.len() != 64 {
return Err(ManifestError::InvalidHexLength {
field: "node_id_hex",
expected: 64,
actual: peer.node_id_hex.len(),
});
}
}
Ok(())
}
pub fn bootstrap_peer(&self) -> Option<&GenesisPeer> {
self.peers.iter().find(|p| p.bootstrap)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn three_peer_manifest_json() -> String {
format!(
r#"{{
"network_name": "montana",
"peers": [
{{
"label": "moscow",
"multiaddr": "/ip4/176.124.208.93/tcp/8444",
"peer_id": "12D3KooWMoscowExamplePeerId",
"account_id_hex": "{a}",
"node_id_hex": "{n}",
"bootstrap": true
}},
{{
"label": "frankfurt",
"multiaddr": "/ip4/89.19.208.158/tcp/8444",
"peer_id": "12D3KooWFrankfurtExamplePeerId",
"account_id_hex": "{b}",
"node_id_hex": "{m}",
"bootstrap": false
}},
{{
"label": "helsinki",
"multiaddr": "/ip4/91.132.142.42/tcp/8444",
"peer_id": "12D3KooWHelsinkiExamplePeerId",
"account_id_hex": "{c}",
"node_id_hex": "{l}",
"bootstrap": false
}}
]
}}"#,
a = "1".repeat(64),
b = "2".repeat(64),
c = "3".repeat(64),
n = "a".repeat(64),
m = "b".repeat(64),
l = "c".repeat(64)
)
}
#[test]
fn parse_three_peer_manifest() {
let toml_text = three_peer_manifest_json();
let m = GenesisManifest::parse(&toml_text).expect("valid manifest");
assert_eq!(m.network_name, "montana");
assert_eq!(m.peers.len(), 3);
assert_eq!(m.peers[0].label, "moscow");
assert!(m.peers[0].bootstrap);
assert!(!m.peers[1].bootstrap);
assert!(!m.peers[2].bootstrap);
assert_eq!(m.bootstrap_peer().unwrap().label, "moscow");
}
#[test]
fn parse_rejects_empty_peers() {
let json_text = r#"{"network_name":"test","peers":[]}"#;
let err = GenesisManifest::parse(json_text).unwrap_err();
assert!(matches!(err, ManifestError::EmptyPeers));
}
#[test]
fn parse_rejects_no_bootstrap() {
let mut m: GenesisManifest = serde_json::from_str(&three_peer_manifest_json()).unwrap();
m.peers[0].bootstrap = false;
let err = m.validate().unwrap_err();
assert!(matches!(err, ManifestError::NoBootstrap));
}
#[test]
fn parse_rejects_multiple_bootstrap() {
let mut m: GenesisManifest = serde_json::from_str(&three_peer_manifest_json()).unwrap();
m.peers[1].bootstrap = true;
let err = m.validate().unwrap_err();
assert!(matches!(err, ManifestError::MultipleBootstrap(2)));
}
#[test]
fn parse_rejects_short_account_id_hex() {
let mut m: GenesisManifest = serde_json::from_str(&three_peer_manifest_json()).unwrap();
m.peers[0].account_id_hex = "abc".to_string();
let err = m.validate().unwrap_err();
assert!(matches!(
err,
ManifestError::InvalidHexLength {
field: "account_id_hex",
expected: 64,
actual: 3
}
));
}
#[test]
fn roundtrip_serialize_parse() {
let original = GenesisManifest::parse(&three_peer_manifest_json()).unwrap();
let serialized = original.to_json_string().unwrap();
let reparsed = GenesisManifest::parse(&serialized).unwrap();
assert_eq!(original, reparsed);
}
#[test]
fn bootstrap_peer_returns_none_when_no_bootstrap() {
let mut m: GenesisManifest = serde_json::from_str(&three_peer_manifest_json()).unwrap();
m.peers[0].bootstrap = false;
assert!(m.bootstrap_peer().is_none());
}
}