247 lines
9.4 KiB
Rust
247 lines
9.4 KiB
Rust
|
|
// 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());
|
|||
|
|
}
|
|||
|
|
}
|