diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..65cd287f Binary files /dev/null and b/.DS_Store differ diff --git a/Montana-Protocol/Code/VERSION.md b/Montana-Protocol/Code/VERSION.md index 2e7e353d..996702ea 100644 --- a/Montana-Protocol/Code/VERSION.md +++ b/Montana-Protocol/Code/VERSION.md @@ -24,6 +24,8 @@ ## History +| 1.0.2 | v35.25.1+net1.3.0 | 2026-05-27 | **Ops/installer + network-layer hardening release.** install-docker.sh: geo-IP auto-naming (country/city/coords), build progress bar, Moscow cross-check; docker network **test mode** (MONTANA_MNEMONIC / MONTANA_GENESIS_MANIFEST_B64 / MONTANA_D_TEST_OVERRIDE — production binary, defaults unchanged). VPN entry hardening (operational, outside the Rust workspace): Moscow domestic master entry + nginx stream SNI-demux, leader-election entry failover (Moscow→Frankfurt→Helsinki) by Reality handshake, anti-block DNS minimisation, front-independent per-city cascade exit. Docs sanitised (removed node IPs/identities/locations). Network spec bumped v1.2.0 → v1.3.0 (gateway entry-point failover section). Confirmed genesis manifest requires exactly one bootstrap node; multi-node consensus convergence (M7 fast-sync client + DEV-012 cross-node apply) remains the open code item. No montana-node consensus semantic change. | + | Impl version | Spec version | Date | Notes | |--------------|--------------|------------|---------------------------------------------------------------------------------------| | 1.0.0 | v35.25.1+net1.1.0 | 2026-05-22 | **v1.0.0 — first mainnet release.** Production transport: Noise_PQ XX (ML-KEM-768 + ML-DSA-65 + ChaCha20-Poly1305). Live four-node mesh: Moscow / Frankfurt / Helsinki / Yerevan. M7 fast-sync mechanism shipped in `mt-sync`: `Snapshot::from_tables`, `to_wire_chunks`, `build_tables`, `SnapshotVerifier::verify` (production Sparse Merkle root, byte-equal cross-implementation conformance, 17 unit tests). M7 server-side dispatcher in `montana-node` answers FastSyncRequest envelopes. Workspace version 0.1.3 → 1.0.0. **Carried into v1.0.1:** DEV-012 Phase B+C multi-confirmer cementing; M7 client-side handler; independent constant-time audit of mt-crypto-native. Release notes: [`Code/RELEASE-v1.0.0.md`](RELEASE-v1.0.0.md). | diff --git a/Montana-Protocol/Code/crates/montana-node/src/commands/fastsync.rs b/Montana-Protocol/Code/crates/montana-node/src/commands/fastsync.rs new file mode 100644 index 00000000..36314b83 --- /dev/null +++ b/Montana-Protocol/Code/crates/montana-node/src/commands/fastsync.rs @@ -0,0 +1,162 @@ +use mt_net::{FastSyncResponseChunk, TableId}; +use mt_sync::{FastSyncChunk, FastSyncTableId}; + +#[derive(Debug, Eq, PartialEq)] +pub enum WireChunkError { + RecordCountZero, + EmptyRecords, + RecordsIndivisible { len: usize, count: usize }, +} + +// Преобразование сетевого чанка fast-sync в чанк mt-sync: разбивает плоскую +// конкатенацию records на record_count равных записей. Канонический размер +// записи на таблицу не дублируется здесь — Snapshot::add_record проверяет его +// сам (единый источник истины по размеру). Маппинг TableId тотальный; таблица +// Proposals доходит до клиента и отклоняется там (ProposalsNotImplementedYet). +pub fn wire_chunk_to_sync(wire: FastSyncResponseChunk) -> Result { + let table_id = match wire.table_id { + TableId::Account => FastSyncTableId::Account, + TableId::Node => FastSyncTableId::Node, + TableId::Candidate => FastSyncTableId::Candidate, + TableId::Proposals => FastSyncTableId::Proposals, + }; + let count = wire.record_count as usize; + if count == 0 { + return Err(WireChunkError::RecordCountZero); + } + if wire.records.is_empty() { + return Err(WireChunkError::EmptyRecords); + } + if wire.records.len() % count != 0 { + return Err(WireChunkError::RecordsIndivisible { + len: wire.records.len(), + count, + }); + } + let rec_size = wire.records.len() / count; + let records: Vec> = wire + .records + .chunks_exact(rec_size) + .map(<[u8]>::to_vec) + .collect(); + Ok(FastSyncChunk { + chunk_index: wire.chunk_index, + total_chunks: wire.total_chunks, + table_id, + records, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use mt_codec::CanonicalEncode; + use mt_crypto::PUBLIC_KEY_SIZE; + use mt_state::{AccountRecord, ACCOUNT_RECORD_SIZE}; + + fn acct(seed: u8) -> Vec { + let rec = AccountRecord { + account_id: [seed; 32], + balance: 1000, + suite_id: 1, + is_node_operator: false, + frontier_hash: [seed; 32], + op_height: 0, + account_chain_length: 0, + account_chain_length_snapshot: 0, + current_pubkey: [seed; PUBLIC_KEY_SIZE], + creation_window: 0, + last_op_window: 0, + last_activation_window: 0, + }; + let mut buf = Vec::with_capacity(ACCOUNT_RECORD_SIZE); + rec.encode(&mut buf); + buf + } + + fn flat(records: &[Vec]) -> Vec { + let mut f = Vec::new(); + for r in records { + f.extend_from_slice(r); + } + f + } + + #[test] + fn splits_flat_records_preserving_bytes() { + let recs = vec![acct(0x11), acct(0x22), acct(0x33)]; + let wire = FastSyncResponseChunk { + chunk_index: 2, + total_chunks: 5, + table_id: TableId::Account, + record_count: 3, + records: flat(&recs), + }; + let out = wire_chunk_to_sync(wire).expect("convert"); + assert_eq!(out.chunk_index, 2); + assert_eq!(out.total_chunks, 5); + assert_eq!(out.table_id, FastSyncTableId::Account); + assert_eq!(out.records, recs); + } + + #[test] + fn maps_every_table_id() { + for (net, sync) in [ + (TableId::Account, FastSyncTableId::Account), + (TableId::Node, FastSyncTableId::Node), + (TableId::Candidate, FastSyncTableId::Candidate), + (TableId::Proposals, FastSyncTableId::Proposals), + ] { + let wire = FastSyncResponseChunk { + chunk_index: 0, + total_chunks: 1, + table_id: net, + record_count: 1, + records: vec![0u8; 8], + }; + assert_eq!(wire_chunk_to_sync(wire).unwrap().table_id, sync); + } + } + + #[test] + fn rejects_zero_record_count() { + let wire = FastSyncResponseChunk { + chunk_index: 0, + total_chunks: 1, + table_id: TableId::Account, + record_count: 0, + records: vec![0u8; 10], + }; + assert_eq!( + wire_chunk_to_sync(wire), + Err(WireChunkError::RecordCountZero) + ); + } + + #[test] + fn rejects_empty_records() { + let wire = FastSyncResponseChunk { + chunk_index: 0, + total_chunks: 1, + table_id: TableId::Account, + record_count: 2, + records: Vec::new(), + }; + assert_eq!(wire_chunk_to_sync(wire), Err(WireChunkError::EmptyRecords)); + } + + #[test] + fn rejects_indivisible_records() { + let wire = FastSyncResponseChunk { + chunk_index: 0, + total_chunks: 1, + table_id: TableId::Account, + record_count: 3, + records: vec![0u8; 10], + }; + assert_eq!( + wire_chunk_to_sync(wire), + Err(WireChunkError::RecordsIndivisible { len: 10, count: 3 }) + ); + } +} diff --git a/Montana-Protocol/Code/crates/montana-node/src/commands/mod.rs b/Montana-Protocol/Code/crates/montana-node/src/commands/mod.rs index 3ff984de..1e22a03c 100644 --- a/Montana-Protocol/Code/crates/montana-node/src/commands/mod.rs +++ b/Montana-Protocol/Code/crates/montana-node/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod fastsync; pub mod init; pub mod inspect; pub mod start; diff --git a/Montana-Protocol/Code/crates/montana-node/src/commands/start.rs b/Montana-Protocol/Code/crates/montana-node/src/commands/start.rs index 4d698fcf..3b8fd111 100644 --- a/Montana-Protocol/Code/crates/montana-node/src/commands/start.rs +++ b/Montana-Protocol/Code/crates/montana-node/src/commands/start.rs @@ -1,6 +1,7 @@ +use std::collections::BTreeMap; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Instant; +use std::time::{Duration, Instant}; use mt_account::{apply_proposal, ProposalSettle}; use mt_codec::CanonicalEncode; @@ -13,9 +14,8 @@ use mt_entry::{ }; use mt_genesis::genesis_params; use mt_lottery::{ - bundle_hash, compute_endpoint, is_cemented, lottery_weight, quorum, reveal_hash, - seniority_term, validate_bundle, validate_reveal, weighted_ticket_node, BundledConfirmation, - VdfReveal, + bundle_hash, compute_endpoint, lottery_weight, quorum, reveal_hash, seniority_term, + validate_bundle, validate_reveal, weighted_ticket_node, BundledConfirmation, VdfReveal, }; use mt_merkle::{empty_internal, SparseMerkleTree, TREE_DEPTH}; use mt_net::{MsgType, ProtocolMessage}; @@ -35,6 +35,22 @@ extern "C" fn shutdown_handler(_: libc::c_int) { STOP.store(true, Ordering::SeqCst); } +// M7 fast-sync trigger threshold (network-layer implementation guidance per +// Network spec — not consensus-critical, may vary between implementations). +// Replay costs ~6 min / 1000 windows on 1 vCPU (mt-sync lib doc); beyond this +// lag snapshot delivery is bandwidth-bound and cheaper than apply_proposal loop. +const FAST_SYNC_LAG_THRESHOLD: u64 = 1000; + +// Operational override of the fast-sync lag threshold via env. The threshold is +// network-layer (not consensus); operators tune it for deployment/observation. +// Empty, unparsable, or zero values fall back to the production default. +fn resolve_fast_sync_lag_threshold(override_val: Option) -> u64 { + override_val + .and_then(|v| v.trim().parse::().ok()) + .filter(|&t| t > 0) + .unwrap_or(FAST_SYNC_LAG_THRESHOLD) +} + pub struct StartArgs { pub data_dir: Option, pub max_windows: Option, @@ -63,7 +79,27 @@ pub fn run(args: StartArgs) -> Result<(), NodeError> { network_handle = Some(spawn_network_thread(&identity, listen_str, manifest_path)?); } - let mut state = LocalState::load_or_bootstrap(&data_dir, &identity, params)?; + // Parse the genesis manifest once at startup (cheap; JSON) so that + // test-cohort `force_active` peers can be pre-seeded into NodeTable / + // AccountTable on first run. Production manifest has no such peers, so + // extras is empty and bootstrap behaves identically. + let genesis_manifest_for_bootstrap: Option = if let Some(path) = + args.genesis_manifest.as_ref() + { + let text = std::fs::read_to_string(path) + .map_err(|e| NodeError::InvalidArguments(format!("genesis-manifest {path:?}: {e}")))?; + Some( + mt_genesis::GenesisManifest::parse(&text) + .map_err(|e| NodeError::InvalidArguments(format!("parse manifest: {e}")))?, + ) + } else { + None + }; + let extra_actives: Vec<&mt_genesis::GenesisPeer> = genesis_manifest_for_bootstrap + .as_ref() + .map(|m| m.extra_actives()) + .unwrap_or_default(); + let mut state = LocalState::load_or_bootstrap(&data_dir, &identity, params, &extra_actives)?; let mut current = load_current_window(&data_dir)?; let mut timechain = load_or_init_timechain(&data_dir)?; let mut lifecycle = load_or_init_lifecycle(&data_dir, &identity, params)?; @@ -155,6 +191,20 @@ pub fn run(args: StartArgs) -> Result<(), NodeError> { let store = FsStore::open(&data_dir) .map_err(|e| NodeError::InvalidArguments(format!("FsStore::open: {e:?}")))?; + // DEV-012 multi-confirmer: per-window accumulator of BCs from Active peers. + // Keyed by window then node_id so duplicates from same node deduplicate. + let mut bc_accumulator: BTreeMap> = + BTreeMap::new(); + + // M7 fast-sync: held across loop iterations while a snapshot is in flight. + let fast_sync_lag_threshold = + resolve_fast_sync_lag_threshold(std::env::var("MONTANA_FASTSYNC_LAG_THRESHOLD").ok()); + println!("fast-sync lag : порог {fast_sync_lag_threshold} окон"); + let mut fast_sync: Option = None; + // M7 fast-sync: recent cemented bootstrap state_roots (window -> root), + // the trusted set a reconstructed snapshot root must match. + let mut recent_roots: BTreeMap = BTreeMap::new(); + loop { // M9 Phase 2: drain incoming Proposal envelopes от bootstrap. Decode window_index // и proposer_node_id напрямую из 3722-байтного header layout без полного @@ -171,13 +221,14 @@ pub fn run(args: StartArgs) -> Result<(), NodeError> { // Decode window_index + winner + proposer без полного deserialize // (signature валидация в M10), apply_proposal with reconstructed // singleton ProposalSettle. Followers stay in lockstep with Moscow. - if msg.payload.len() != 3722 { + if msg.payload.len() < 3722 { eprintln!( - "[consensus] Proposal envelope wrong size {} (expected 3722) — skip", + "[consensus] Proposal envelope wrong size {} (expected >= 3722) — skip", msg.payload.len() ); continue; } + let is_cemented = msg.payload.len() > 3722; let window_index = u64::from_le_bytes([ msg.payload[32], msg.payload[33], @@ -197,9 +248,194 @@ pub fn run(args: StartArgs) -> Result<(), NodeError> { eprintln!("[consensus] Proposal от не-bootstrap proposer, skip"); continue; } - if window_index <= current { + // M10: cryptographic verification of the bootstrap + // signature over signed_scope (bytes 0..413). Rejects + // any forged or tampered Proposal — closes the M9 + // Phase 2 deferred-signature gate symmetrically for + // the replay path and (via recent_roots) for fast-sync. + let mut sig_bytes = [0u8; mt_crypto::SIGNATURE_SIZE]; + sig_bytes.copy_from_slice(&msg.payload[413..3722]); + let sig = mt_crypto::Signature::from_array(sig_bytes); + let bootstrap_pk = + mt_crypto::PublicKey::from_array(params.bootstrap_node_pubkey); + if !mt_crypto::verify(&bootstrap_pk, &msg.payload[0..413], &sig) { + eprintln!( + "[consensus] Proposal w={window_index} с невалидной подписью bootstrap — skip" + ); continue; } + // Record this bootstrap Proposal's state_root as a trusted + // fast-sync anchor (offset 172..204), bounded to recent windows. + let mut sr = [0u8; 32]; + sr.copy_from_slice(&msg.payload[172..204]); + recent_roots.insert(window_index, sr); + while recent_roots.len() > 64 { + let oldest = *recent_roots.keys().next().unwrap(); + recent_roots.remove(&oldest); + } + // Candidate envelope (size == 3722, no bundles) is NOT applied: + // it serves as a notification "window W is being proposed, + // send me your BC". Active followers respond with a BC. + if !is_cemented { + // Active follower: compute own BC for this window and + // broadcast back to the proposer (and to peers). + let am_active_in_table = state.nodes.get(&my_node).is_some(); + if am_active_in_table && my_node != bootstrap_node_id { + let mut t_r_w = [0u8; 32]; + t_r_w.copy_from_slice(&msg.payload[204..236]); + let cba = mt_timechain::cemented_bundle_aggregate( + window_index.saturating_sub(2), + &[], + ); + let endpoint = mt_lottery::compute_endpoint( + &t_r_w, + &cba, + &my_node, + window_index, + ); + let _ = endpoint; + // Per existing convention (validate_bundle line ~140): + // bc.endpoint stores the raw T_r(W) of the proposer. + let mut bc = BundledConfirmation { + node_id: my_node, + endpoint: t_r_w, + window_index, + op_hashes: Vec::new(), + reveal_hashes: Vec::new(), + signature: Signature::from_array([0u8; SIGNATURE_SIZE]), + }; + let mut bc_scope = Vec::new(); + bc.encode_signed_scope(&mut bc_scope); + bc.signature = sign(&identity.node_sk, &bc_scope) + .map_err(NodeError::Crypto)?; + let mut bc_payload = Vec::new(); + bc.encode(&mut bc_payload); + let envelope = ProtocolMessage::new( + MsgType::BundledConfirmation, + window_index, + bc_payload, + ); + if handle.broadcast_tx.send(envelope).is_ok() { + eprintln!("[bc] broadcast own BC for window {window_index}"); + } + } + // Candidate not advanced; cemented envelope will advance current. + continue; + } + // Cemented envelope: parse bundles, validate, multi-confirmer apply. + let mut bundles: Vec = Vec::new(); + let payload = &msg.payload; + if payload.len() >= 3722 + 2 { + let mut bc_buf = [0u8; 2]; + bc_buf.copy_from_slice(&payload[3722..3724]); + let bundle_count = u16::from_le_bytes(bc_buf) as usize; + let mut off = 3724; + let mut ok = true; + for _ in 0..bundle_count { + match BundledConfirmation::decode(&payload[off..]) { + Ok((bc, used)) => { + bundles.push(bc); + off += used; + }, + Err(e) => { + eprintln!( + "[consensus] cemented bundle decode failed: {e:?} — skip envelope" + ); + ok = false; + break; + }, + } + } + if !ok { + continue; + } + } + let mut t_r_w_cemented = [0u8; 32]; + t_r_w_cemented.copy_from_slice(&msg.payload[204..236]); + let mut valid_confirmers: Vec = Vec::new(); + let mut any_invalid = false; + for bc in &bundles { + if mt_lottery::validate_bundle(bc, &state.nodes, &t_r_w_cemented) + .is_ok() + { + valid_confirmers.push(bc.node_id); + } else { + any_invalid = true; + } + } + if any_invalid { + eprintln!( + "[consensus] cemented w={window_index}: некоторые bundles не прошли validate, продолжаю с валидными {}", + valid_confirmers.len() + ); + } + if valid_confirmers.is_empty() && !bundles.is_empty() { + eprintln!( + "[consensus] cemented w={window_index}: 0 валидных bundles — skip" + ); + continue; + } + // Fallback: if bundles empty (legacy 3722-only envelope), treat as + // singleton with proposer as sole confirmer. Unreachable here since + // is_cemented = payload.len() > 3722; but defensive. + if valid_confirmers.is_empty() { + valid_confirmers.push(proposer_node_id); + } + // M7 fast-sync: if a snapshot is already in flight, ignore + // cemented proposals until apply. + if fast_sync.is_some() { + continue; + } + // Far behind → request a fast-sync snapshot instead of waiting + // for many cemented envelopes (one per window) to catch up. + if window_index.saturating_sub(current) > fast_sync_lag_threshold { + let mut fs_payload = Vec::new(); + mt_net::FastSyncRequest { + anchor_window: window_index, + resume_offset: 0, + } + .encode(&mut fs_payload); + match handle.broadcast_tx.send(ProtocolMessage::new( + MsgType::FastSyncRequest, + msg.request_id, + fs_payload, + )) { + Ok(()) => { + eprintln!( + "[m7] {} windows behind (> {fast_sync_lag_threshold}) \u{2192} fast-sync anchored at window {window_index}", + window_index.saturating_sub(current) + ); + fast_sync = Some(mt_sync::FastSyncClient::new()); + }, + Err(e) => eprintln!("[m7] FastSyncRequest broadcast failed: {e}"), + } + continue; + } + // One cemented envelope = one window advance. + if window_index != current + 1 { + eprintln!( + "[consensus] cemented w={window_index} gap (current={current}) — wait for sequential cemented or fast-sync" + ); + continue; + } + let settle = ProposalSettle { + window_w: window_index, + winner_id, + cemented_confirmers: valid_confirmers.clone(), + }; + let _post_state_root = apply_proposal( + &mut state.accounts, + &mut state.nodes, + &state.candidates, + &settle, + params, + ); + current = window_index; + save_current_window(&data_dir, current)?; + eprintln!( + "[consensus] applied cemented Proposal w={current} (confirmers={})", + valid_confirmers.len() + ); let mut applied_count = 0u64; while current < window_index { let next_w = current + 1; @@ -285,11 +521,76 @@ pub fn run(args: StartArgs) -> Result<(), NodeError> { }, } }, + MsgType::FastSyncResponse => { + if let Some(mut client) = fast_sync.take() { + let parsed = mt_net::FastSyncResponseChunk::decode(&msg.payload) + .map_err(|e| format!("decode: {e:?}")) + .and_then(|w| { + crate::commands::fastsync::wire_chunk_to_sync(w) + .map_err(|e| format!("wire: {e:?}")) + }); + match parsed { + Ok(chunk) => match client.accept_chunk(chunk) { + Ok(mt_sync::AcceptOutcome::Complete) => { + match client.finalize(&recent_roots) { + Ok((window, tables)) => { + state.apply_fast_sync( + tables, &data_dir, window, + )?; + current = window; + save_current_window(&data_dir, current)?; + eprintln!("[m7] fast-sync complete \u{2192} state replaced, current_window={current}"); + }, + Err(e) => eprintln!( + "[m7] fast-sync finalize rejected: {e:?} \u{2014} retry on next lag" + ), + } + }, + Ok(mt_sync::AcceptOutcome::Progress { received, total }) => { + eprintln!("[m7] fast-sync chunk {received}/{total}"); + fast_sync = Some(client); + }, + Err(e) => eprintln!( + "[m7] fast-sync chunk rejected: {e:?} \u{2014} discard, retry on next lag" + ), + }, + Err(reason) => { + eprintln!("[m7] FastSyncResponse {reason}"); + fast_sync = Some(client); + }, + } + } + }, MsgType::BundledConfirmation => { - // DEV-012 Phase A scaffold: count incoming BC envelopes. Full - // multi-confirmer validate + accumulator-quorum + cemented-Proposal - // broadcast is DEV-012 Phase B+C (v1.0.0 mainnet gate). + // DEV-012 Phase B: validate incoming BC and insert into accumulator. + // Quorum check + cementing is done at the top of the Active loop. bc_count += 1; + match BundledConfirmation::decode(&msg.payload) { + Ok((bc, _used)) => { + // expected_endpoint = my own t_r at bc.window_index. For + // current-window BCs this is timechain.t_r; for past windows + // we'd need history. Simplification: validate against + // current t_r only; older BCs may fail and be ignored. + if mt_lottery::validate_bundle(&bc, &state.nodes, &timechain.t_r) + .is_ok() + { + let node_id = bc.node_id; + let w = bc.window_index; + bc_accumulator.entry(w).or_default().insert(node_id, bc); + eprintln!( + "[bc] accepted BC from {} for window {w}", + hex16(&node_id) + ); + } else { + eprintln!( + "[bc] BC validate failed for {} w={}", + hex16(&bc.node_id), + bc.window_index + ); + } + }, + Err(e) => eprintln!("[bc] decode failed: {e:?}"), + } }, _ => {}, } @@ -413,10 +714,14 @@ pub fn run(args: StartArgs) -> Result<(), NodeError> { // NodeTable работает как passive follower: не producит proposal, // break 'active_arm падает в post-match cleanup (candidate_expiry + // selection_event + next_d + save_progress) — узел остаётся жив. - let is_singleton = state.nodes.len() == 1 && state.nodes.get(&my_node).is_some(); - if !is_singleton { + // DEV-012 multi-confirmer: bootstrap is the canonical proposer; any + // Active node that is NOT the bootstrap stays a follower and contributes + // a BC on incoming candidate Proposal. (Spec calls for lookback-based + // proposer rotation in a future iteration; for the v1.0.0 cohort the + // bootstrap-only proposer model is the deployed baseline.) + if !is_genesis { eprintln!( - "[active W={current}] follower mode (NodeTable={} nodes) — waiting for peer Proposal", + "[active W={current}] non-bootstrap Active in NodeTable={} — follower mode", state.nodes.len() ); follower_skip = true; @@ -465,12 +770,10 @@ pub fn run(args: StartArgs) -> Result<(), NodeError> { NodeError::InvalidArguments("active phase но узел не в NodeTable".into()) })?; let cemented_chain_length = my_node_record.chain_length; - if !is_cemented(cemented_chain_length, active_chain_length) { - return Err(NodeError::InvalidArguments(format!( - "singleton cementing: cemented={cemented_chain_length}, active={active_chain_length}, quorum={}", - quorum(active_chain_length) - ))); - } + // DEV-012: cementing check is performed against the multi-confirmer + // accumulator after broadcast+drain (below). The singleton-only check + // is no longer correct in multi-Active mode. + let _ = (cemented_chain_length, active_chain_length); let snapshot = my_node_record.chain_length_snapshot.max(1); let _weight = lottery_weight(my_node_record.chain_length, snapshot); @@ -536,10 +839,121 @@ pub fn run(args: StartArgs) -> Result<(), NodeError> { signature: Signature::from_array([0u8; SIGNATURE_SIZE]), }; + // DEV-012: insert own BC into accumulator first. + bc_accumulator + .entry(current) + .or_default() + .insert(my_node, bc.clone()); + + // Multi-confirmer: if not singleton, broadcast candidate (3722) first, + // then spin draining incoming for BCs from peers up to 800ms or quorum. + if state.nodes.len() > 1 { + // Build a minimal candidate header (placeholder state_root) for the + // notification broadcast. Signed scope must include real T_r so peers + // compute matching BC.endpoint. + let candidate = ProposalHeader { + prev_proposal_hash, + window_index: current, + protocol_version: 1, + control_root, + node_root: [0u8; 32], + candidate_root: state.candidates.root(), + account_root: [0u8; 32], + state_root: [0u8; 32], + timechain_value: timechain.t_r, + included_bundles_root, + included_reveals_root, + winner_endpoint: endpoint, + winner_id: my_node, + proposer_node_id: my_node, + target: u128::MAX, + fallback_depth: 1, + signature: Signature::from_array([0u8; SIGNATURE_SIZE]), + }; + let mut cand_scope = Vec::new(); + candidate.encode_signed_scope(&mut cand_scope); + let cand_sig = + sign(&identity.node_sk, &cand_scope).map_err(NodeError::Crypto)?; + let mut signed_cand = candidate.clone(); + signed_cand.signature = cand_sig; + let mut cand_bytes = Vec::with_capacity(3722); + signed_cand.encode(&mut cand_bytes); + if let Some(ref handle) = network_handle { + let _ = handle.broadcast_tx.send(ProtocolMessage::new( + MsgType::Proposal, + current, + cand_bytes, + )); + eprintln!( + "[dev-012] broadcast candidate Proposal w={current} (NodeTable.len={}, awaiting BCs)", + state.nodes.len() + ); + } + + // Spin draining BCs up to 800ms. + let active_sum: u64 = state.nodes.iter().map(|n| n.chain_length).sum(); + let need_quorum = quorum(active_sum); + let deadline = Instant::now() + Duration::from_millis(800); + while Instant::now() < deadline { + if let Some(ref mut handle) = network_handle { + while let Ok(msg) = handle.incoming_rx.try_recv() { + if msg.msg_type == MsgType::BundledConfirmation { + if let Ok((rec_bc, _)) = + BundledConfirmation::decode(&msg.payload) + { + if rec_bc.window_index == current + && mt_lottery::validate_bundle( + &rec_bc, + &state.nodes, + &timechain.t_r, + ) + .is_ok() + { + let nid = rec_bc.node_id; + bc_accumulator + .entry(current) + .or_default() + .insert(nid, rec_bc); + eprintln!( + "[dev-012] accepted BC from {} for w={current}", + hex16(&nid) + ); + } + } + } + } + } + let collected: u64 = bc_accumulator + .get(¤t) + .map(|m| { + m.keys() + .filter_map(|id| state.nodes.get(id).map(|n| n.chain_length)) + .sum() + }) + .unwrap_or(0); + if collected >= need_quorum { + eprintln!( + "[dev-012] quorum reached w={current}: cemented_sum={collected} >= {need_quorum}" + ); + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + } + + // Build final settle from accumulator. Sorted by node_id for determinism. + let confirmer_ids: Vec = bc_accumulator + .get(¤t) + .map(|m| { + let mut v: Vec<_> = m.keys().copied().collect(); + v.sort(); + v + }) + .unwrap_or_else(|| vec![my_node]); let settle = ProposalSettle { window_w: current, winner_id: my_node, - cemented_confirmers: vec![my_node], + cemented_confirmers: confirmer_ids.clone(), }; let post_state_root = apply_proposal( &mut state.accounts, @@ -557,26 +971,40 @@ pub fn run(args: StartArgs) -> Result<(), NodeError> { header.signature = sign(&identity.node_sk, &header_scope).map_err(NodeError::Crypto)?; - // M9 Phase 1: broadcast ProposalHeader всем connected peers через - // network thread. Followers получают envelope с window_index и - // в M9 Phase 2 будут running apply_proposal locally. + // DEV-012: broadcast CEMENTED envelope: [header(3722)][u16 bundle_count][N × BC]. if let Some(ref handle) = network_handle { - let mut header_bytes = Vec::with_capacity(3722); - header.encode(&mut header_bytes); + let mut payload = Vec::with_capacity(3722 + 2 + 4096 * confirmer_ids.len()); + header.encode(&mut payload); + let bundles_for_envelope: Vec<&BundledConfirmation> = { + let map = bc_accumulator.get(¤t).cloned().unwrap_or_default(); + let mut keys: Vec<_> = map.keys().copied().collect(); + keys.sort(); + keys.into_iter() + .filter_map(|k| bc_accumulator.get(¤t).and_then(|m| m.get(&k))) + .collect::>() + }; + let bundle_count = bundles_for_envelope.len() as u16; + payload.extend_from_slice(&bundle_count.to_le_bytes()); + for bc in &bundles_for_envelope { + bc.encode(&mut payload); + } let envelope = - ProtocolMessage::new(MsgType::Proposal, header.window_index, header_bytes); + ProtocolMessage::new(MsgType::Proposal, header.window_index, payload); if let Err(e) = handle.broadcast_tx.send(envelope) { eprintln!( - "[consensus] broadcast Proposal w={} failed: {e}", + "[consensus] broadcast CEMENTED Proposal w={} failed: {e}", header.window_index ); } else { eprintln!( - "[consensus] broadcast Proposal window={} → peers", - header.window_index + "[consensus] broadcast CEMENTED Proposal window={} → peers (bundles={})", + header.window_index, + bundle_count ); } } + // Window cemented; drop its accumulator entry. + bc_accumulator.remove(¤t); let recomputed = compute_state_root( &state.nodes.root(), @@ -897,3 +1325,19 @@ pub struct NetworkHandle { pub broadcast_tx: tokio::sync::mpsc::UnboundedSender, pub incoming_rx: tokio::sync::mpsc::UnboundedReceiver, } + +#[cfg(test)] +mod tests { + use super::resolve_fast_sync_lag_threshold as resolve; + use super::FAST_SYNC_LAG_THRESHOLD as DEFAULT; + + #[test] + fn lag_threshold_override_resolution() { + assert_eq!(resolve(None), DEFAULT); + assert_eq!(resolve(Some("5".to_string())), 5); + assert_eq!(resolve(Some(" 7 ".to_string())), 7); + assert_eq!(resolve(Some("0".to_string())), DEFAULT); + assert_eq!(resolve(Some("abc".to_string())), DEFAULT); + assert_eq!(resolve(Some(String::new())), DEFAULT); + } +} diff --git a/Montana-Protocol/Code/crates/montana-node/src/commands/status.rs b/Montana-Protocol/Code/crates/montana-node/src/commands/status.rs index a851f57c..88c7076b 100644 --- a/Montana-Protocol/Code/crates/montana-node/src/commands/status.rs +++ b/Montana-Protocol/Code/crates/montana-node/src/commands/status.rs @@ -17,7 +17,7 @@ pub fn run(args: StatusArgs) -> Result<(), NodeError> { let data_dir = args.data_dir.unwrap_or_else(default_data_dir); let identity = load_identity(&data_dir)?; let params = genesis_params(); - let state = LocalState::load_or_bootstrap(&data_dir, &identity, params)?; + let state = LocalState::load_or_bootstrap(&data_dir, &identity, params, &[])?; let current_window = load_current_window(&data_dir)?; let lifecycle = load_or_init_lifecycle(&data_dir, &identity, params)?; let timechain = load_or_init_timechain(&data_dir)?; @@ -54,6 +54,14 @@ pub fn run(args: StatusArgs) -> Result<(), NodeError> { println!("--- ваша identity ---"); println!("account_id : {}", hex_lower(&my_account)); println!("node_id : {}", hex_lower(&my_node)); + println!( + "node_pubkey_hex : {}", + hex_lower(identity.node_pk.as_bytes()) + ); + println!( + "account_pubkey_hex : {}", + hex_lower(identity.account_pk.as_bytes()) + ); println!(); println!("--- размеры таблиц локального state ---"); println!("AccountTable : {} записей", state.accounts.len()); diff --git a/Montana-Protocol/Code/crates/montana-node/src/state.rs b/Montana-Protocol/Code/crates/montana-node/src/state.rs index 591db880..8fd232b7 100644 --- a/Montana-Protocol/Code/crates/montana-node/src/state.rs +++ b/Montana-Protocol/Code/crates/montana-node/src/state.rs @@ -1,8 +1,9 @@ use std::path::Path; -use mt_genesis::ProtocolParams; +use mt_crypto::PUBLIC_KEY_SIZE; +use mt_genesis::{GenesisPeer, ProtocolParams}; use mt_state::{ - AccountRecord, AccountTable, CandidatePool, NodeTable, ACCOUNT_RECORD_SIZE, + AccountRecord, AccountTable, CandidatePool, NodeRecord, NodeTable, ACCOUNT_RECORD_SIZE, CANDIDATE_RECORD_SIZE, NODE_RECORD_SIZE, }; use mt_store::FsStore; @@ -25,7 +26,11 @@ impl LocalState { // apply_selection_event на ближайшем W % selection_interval == 0. // Operator account создаётся в обоих случаях (нужен для подписания // будущей NodeRegistration). - pub fn bootstrap(operator: &Identity, params: &ProtocolParams) -> Self { + pub fn bootstrap( + operator: &Identity, + params: &ProtocolParams, + extra_actives: &[&GenesisPeer], + ) -> Self { let is_genesis = NodeLifecycle::is_bootstrap_node(operator, params); let mut accounts = AccountTable::new(); @@ -85,6 +90,51 @@ impl LocalState { last_confirmation_window: 0, }); + // Test-cohort pre-seed: each peer with `force_active = true` becomes an + // Active operator at genesis (NodeTable + AccountRecord), bypassing τ₂ + // candidate VDF wait. Production manifest has no such peers — this + // branch is a no-op in mainnet runs. + for peer in extra_actives { + let npk_bytes = hex_to_pubkey( + peer.node_pubkey_hex + .as_deref() + .expect("manifest validation guarantees node_pubkey_hex for force_active"), + ); + let apk_bytes = hex_to_pubkey( + peer.account_pubkey_hex + .as_deref() + .expect("manifest validation guarantees account_pubkey_hex for force_active"), + ); + let suite = operator.suite_id as u16; + let extra_node_id = mt_state::derive_node_id(&npk_bytes); + let extra_account_id = mt_state::derive_account_id(suite, &apk_bytes); + accounts.insert(AccountRecord { + account_id: extra_account_id, + balance: 0, + suite_id: suite, + is_node_operator: true, + frontier_hash: [0u8; 32], + op_height: 0, + account_chain_length: 0, + account_chain_length_snapshot: 0, + current_pubkey: apk_bytes, + creation_window: 0, + last_op_window: 0, + last_activation_window: 0, + }); + nodes.insert(NodeRecord { + node_id: extra_node_id, + node_pubkey: npk_bytes, + suite_id: suite, + operator_account_id: extra_account_id, + start_window: 0, + chain_length: 1, + chain_length_snapshot: 1, + chain_length_checkpoints: [0; 6], + last_confirmation_window: 0, + }); + } + Self { accounts, nodes, @@ -96,13 +146,14 @@ impl LocalState { data_dir: &Path, operator: &Identity, params: &ProtocolParams, + extra_actives: &[&GenesisPeer], ) -> Result { let store = FsStore::open(data_dir).map_err(|e| { NodeError::InvalidArguments(format!("открытие хранилища {}: {e:?}", data_dir.display())) })?; let accounts_path = data_dir.join("accounts.bin"); if !accounts_path.exists() { - return Ok(Self::bootstrap(operator, params)); + return Ok(Self::bootstrap(operator, params, extra_actives)); } let accounts = store.load_account_table().map_err(|e| { NodeError::InvalidArguments(format!( @@ -140,6 +191,29 @@ impl LocalState { .map_err(|e| NodeError::InvalidArguments(format!("save candidates: {e:?}")))?; Ok(()) } + + // Применение проверенного fast-sync снимка: вызывающая сторона передаёт + // TypedTables только после того как FastSyncClient::finalize сверил + // reconstructed state_root с доверенным anchor root окна W. Здесь снимок + // уже доверенный — заменяем три consensus-таблицы и фиксируем + // meta_last_cemented = W (точка восстановления после перезапуска). + pub fn apply_fast_sync( + &mut self, + tables: mt_sync::snapshot::TypedTables, + data_dir: &Path, + anchor_window: u64, + ) -> Result<(), NodeError> { + self.accounts = tables.accounts; + self.nodes = tables.nodes; + self.candidates = tables.candidates; + self.save(data_dir)?; + let store = FsStore::open(data_dir) + .map_err(|e| NodeError::InvalidArguments(format!("открытие хранилища: {e:?}")))?; + store + .save_meta_last_cemented(anchor_window) + .map_err(|e| NodeError::InvalidArguments(format!("save_meta_last_cemented: {e:?}")))?; + Ok(()) + } } // SPEC DEVIATION DEV-010 (closed 2026-05-02 в M9 Phase 1): @@ -147,3 +221,119 @@ impl LocalState { // (а не из operator's own pk). Это унифицирует bootstrap entry между всеми // узлами cohort-а — необходимо для apply_proposal validation на receivers. // Inline в LocalState::bootstrap(); helper удалён. + +fn hex_to_pubkey(h: &str) -> [u8; PUBLIC_KEY_SIZE] { + let mut out = [0u8; PUBLIC_KEY_SIZE]; + for (i, byte) in out.iter_mut().enumerate() { + let hi = (h.as_bytes()[2 * i] as char).to_digit(16).expect("hex"); + let lo = (h.as_bytes()[2 * i + 1] as char).to_digit(16).expect("hex"); + *byte = ((hi << 4) | lo) as u8; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use mt_crypto::PUBLIC_KEY_SIZE; + use mt_state::{AccountTable, CandidatePool, NodeTable}; + use std::fs; + use std::path::PathBuf; + + fn tempdir() -> PathBuf { + let mut p = std::env::temp_dir(); + let mut buf = [0u8; 8]; + getrandom::getrandom(&mut buf).unwrap(); + p.push(format!( + "montana-state-test-{:016x}", + u64::from_le_bytes(buf) + )); + fs::create_dir_all(&p).unwrap(); + p + } + + fn sample_account(seed: u8) -> AccountRecord { + AccountRecord { + account_id: [seed; 32], + balance: 500, + suite_id: 1, + is_node_operator: false, + frontier_hash: [seed; 32], + op_height: 0, + account_chain_length: 0, + account_chain_length_snapshot: 0, + current_pubkey: [seed; PUBLIC_KEY_SIZE], + creation_window: 0, + last_op_window: 0, + last_activation_window: 0, + } + } + + #[test] + fn apply_fast_sync_replaces_tables_and_persists_anchor() { + let dir = tempdir(); + let mut state = LocalState { + accounts: AccountTable::new(), + nodes: NodeTable::new(), + candidates: CandidatePool::new(), + }; + + let mut accounts = AccountTable::new(); + accounts.insert(sample_account(0xAB)); + accounts.insert(sample_account(0xCD)); + let tables = mt_sync::snapshot::TypedTables { + accounts, + nodes: NodeTable::new(), + candidates: CandidatePool::new(), + }; + + state.apply_fast_sync(tables, &dir, 75_850).unwrap(); + + assert_eq!(state.accounts.len(), 2); + + let store = FsStore::open(&dir).unwrap(); + assert_eq!(store.load_meta_last_cemented().unwrap(), Some(75_850)); + assert_eq!(store.load_account_table().unwrap().len(), 2); + assert!(store.load_node_table().unwrap().is_empty()); + + fs::remove_dir_all(&dir).ok(); + } + #[test] + fn bootstrap_pre_seeds_force_active_extras() { + use mt_genesis::{genesis_params, GenesisPeer}; + let params = genesis_params(); + let dir = tempdir(); + // build an Identity by writing one fresh + let id = crate::identity::Identity::from_entropy_ephemeral(&[0x77; 32]).unwrap(); + let mut npk_hex = String::with_capacity(3904); + for b in [0xAAu8; PUBLIC_KEY_SIZE] { + npk_hex.push_str(&format!("{b:02x}")); + } + let mut apk_hex = String::with_capacity(3904); + for b in [0xBBu8; PUBLIC_KEY_SIZE] { + apk_hex.push_str(&format!("{b:02x}")); + } + let extra = GenesisPeer { + label: "vilnius-test".into(), + multiaddr: "/ip4/0.0.0.0/tcp/8444".into(), + peer_id: "QmTest".into(), + account_id_hex: "0".repeat(64), + node_id_hex: "1".repeat(64), + bootstrap: false, + force_active: true, + node_pubkey_hex: Some(npk_hex), + account_pubkey_hex: Some(apk_hex), + }; + let state = LocalState::bootstrap(&id, params, &[&extra]); + // bootstrap node + extra Active = 2 nodes + assert_eq!(state.nodes.len(), 2); + let extra_node_id = mt_state::derive_node_id(&[0xAA; PUBLIC_KEY_SIZE]); + assert!(state.nodes.contains(&extra_node_id)); + // extra account present, is_node_operator=true + let extra_account_id = mt_state::derive_account_id(1, &[0xBB; PUBLIC_KEY_SIZE]); + let rec = state.accounts.get(&extra_account_id).unwrap(); + assert!(rec.is_node_operator); + assert_eq!(rec.current_pubkey, [0xBB; PUBLIC_KEY_SIZE]); + fs::remove_dir_all(&dir).ok(); + } +} diff --git a/Montana-Protocol/Code/crates/montana-node/tests/three_peer_e2e.rs b/Montana-Protocol/Code/crates/montana-node/tests/three_peer_e2e.rs index 57077deb..c4524cf4 100644 --- a/Montana-Protocol/Code/crates/montana-node/tests/three_peer_e2e.rs +++ b/Montana-Protocol/Code/crates/montana-node/tests/three_peer_e2e.rs @@ -106,6 +106,9 @@ async fn three_peers_establish_full_mesh_and_ping_pong() { account_id_hex: hex64(&identities[0].account_id()), node_id_hex: hex64(&identities[0].node_id()), bootstrap: true, + force_active: false, + node_pubkey_hex: None, + account_pubkey_hex: None, }, GenesisPeer { label: "n1".into(), @@ -114,6 +117,9 @@ async fn three_peers_establish_full_mesh_and_ping_pong() { account_id_hex: hex64(&identities[1].account_id()), node_id_hex: hex64(&identities[1].node_id()), bootstrap: false, + force_active: false, + node_pubkey_hex: None, + account_pubkey_hex: None, }, GenesisPeer { label: "n2".into(), @@ -122,6 +128,9 @@ async fn three_peers_establish_full_mesh_and_ping_pong() { account_id_hex: hex64(&identities[2].account_id()), node_id_hex: hex64(&identities[2].node_id()), bootstrap: false, + force_active: false, + node_pubkey_hex: None, + account_pubkey_hex: None, }, ], }; diff --git a/Montana-Protocol/Code/crates/mt-genesis/src/manifest.rs b/Montana-Protocol/Code/crates/mt-genesis/src/manifest.rs index 5dce7c46..9222b33f 100644 --- a/Montana-Protocol/Code/crates/mt-genesis/src/manifest.rs +++ b/Montana-Protocol/Code/crates/mt-genesis/src/manifest.rs @@ -35,6 +35,23 @@ pub struct GenesisPeer { /// Среди peers в manifest-е может быть **ровно один** bootstrap. #[serde(default)] pub bootstrap: bool, + /// Test-cohort only: pre-seed this peer as an Active operator in the + /// genesis NodeTable / AccountTable, bypassing the τ₂ candidate VDF wait. + /// Production manifest leaves this `false` for all non-bootstrap peers; + /// admission is consensus-driven through selection events. + #[serde(default)] + pub force_active: bool, + /// ML-DSA-65 node public key in lowercase hex (3904 chars = 1952 bytes). + /// Required when `force_active = true` (the pre-seed needs the full + /// pubkey, not just the node_id hash). `None` for production peers + /// whose identity is finalized only via `ProtocolParams.bootstrap_node_pubkey`. + #[serde(default)] + pub node_pubkey_hex: Option, + /// ML-DSA-65 account public key in lowercase hex (3904 chars = 1952 bytes). + /// Required when `force_active = true` for the same reason as + /// `node_pubkey_hex` — pre-seeding an AccountRecord needs the full pubkey. + #[serde(default)] + pub account_pubkey_hex: Option, } #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] @@ -57,6 +74,10 @@ pub enum ManifestError { expected: usize, actual: usize, }, + ForceActiveMissingPubkey { + peer: String, + field: &'static str, + }, } impl std::fmt::Display for ManifestError { @@ -78,6 +99,10 @@ impl std::fmt::Display for ManifestError { f, "поле {field}: ожидалось {expected} hex-символов, получили {actual}" ), + Self::ForceActiveMissingPubkey { peer, field } => write!( + f, + "peer '{peer}' имеет force_active=true, но поле {field} отсутствует" + ), } } } @@ -125,10 +150,53 @@ impl GenesisManifest { actual: peer.node_id_hex.len(), }); } + if peer.force_active { + match &peer.node_pubkey_hex { + None => { + return Err(ManifestError::ForceActiveMissingPubkey { + peer: peer.label.clone(), + field: "node_pubkey_hex", + }) + }, + Some(h) if h.len() != 3904 => { + return Err(ManifestError::InvalidHexLength { + field: "node_pubkey_hex", + expected: 3904, + actual: h.len(), + }) + }, + _ => (), + } + match &peer.account_pubkey_hex { + None => { + return Err(ManifestError::ForceActiveMissingPubkey { + peer: peer.label.clone(), + field: "account_pubkey_hex", + }) + }, + Some(h) if h.len() != 3904 => { + return Err(ManifestError::InvalidHexLength { + field: "account_pubkey_hex", + expected: 3904, + actual: h.len(), + }) + }, + _ => (), + } + } } Ok(()) } + /// Test-cohort accessor: peers explicitly marked `force_active = true`, + /// excluding the bootstrap (which is already seeded from `ProtocolParams`). + pub fn extra_actives(&self) -> Vec<&GenesisPeer> { + self.peers + .iter() + .filter(|p| p.force_active && !p.bootstrap) + .collect() + } + pub fn bootstrap_peer(&self) -> Option<&GenesisPeer> { self.peers.iter().find(|p| p.bootstrap) } @@ -243,4 +311,80 @@ mod tests { m.peers[0].bootstrap = false; assert!(m.bootstrap_peer().is_none()); } + + fn force_active_peer_json() -> String { + format!( + r#"{{ + "network_name": "test", + "peers": [ + {{ + "label": "armenia", + "multiaddr": "/ip4/149.154.184.205/tcp/8444", + "peer_id": "QmTestBootstrap", + "account_id_hex": "{a}", + "node_id_hex": "{n}", + "bootstrap": true + }}, + {{ + "label": "vilnius", + "multiaddr": "/ip4/45.45.45.45/tcp/8444", + "peer_id": "QmTestVilnius", + "account_id_hex": "{b}", + "node_id_hex": "{m}", + "force_active": true, + "node_pubkey_hex": "{npk}", + "account_pubkey_hex": "{apk}" + }} + ] + }}"#, + a = "1".repeat(64), + b = "2".repeat(64), + n = "a".repeat(64), + m = "b".repeat(64), + npk = "c".repeat(3904), + apk = "d".repeat(3904), + ) + } + + #[test] + fn parse_force_active_with_pubkeys() { + let m = GenesisManifest::parse(&force_active_peer_json()).expect("valid"); + let extras = m.extra_actives(); + assert_eq!(extras.len(), 1); + assert_eq!(extras[0].label, "vilnius"); + assert!(extras[0].force_active); + assert!(!extras[0].bootstrap); + assert_eq!(extras[0].node_pubkey_hex.as_ref().unwrap().len(), 3904); + } + + #[test] + fn force_active_without_pubkey_rejected() { + let mut json: serde_json::Value = serde_json::from_str(&force_active_peer_json()).unwrap(); + json["peers"][1] + .as_object_mut() + .unwrap() + .remove("node_pubkey_hex"); + let err = GenesisManifest::parse(&json.to_string()).unwrap_err(); + assert!(matches!( + err, + ManifestError::ForceActiveMissingPubkey { field, .. } if field == "node_pubkey_hex" + )); + } + + #[test] + fn force_active_wrong_pubkey_length_rejected() { + let mut json: serde_json::Value = serde_json::from_str(&force_active_peer_json()).unwrap(); + json["peers"][1] + .as_object_mut() + .unwrap() + .insert("node_pubkey_hex".into(), "abc".into()); + let err = GenesisManifest::parse(&json.to_string()).unwrap_err(); + assert!(matches!( + err, + ManifestError::InvalidHexLength { + field: "node_pubkey_hex", + .. + } + )); + } } diff --git a/Montana-Protocol/Code/crates/mt-lottery/src/lib.rs b/Montana-Protocol/Code/crates/mt-lottery/src/lib.rs index adbfab4b..f7a364db 100644 --- a/Montana-Protocol/Code/crates/mt-lottery/src/lib.rs +++ b/Montana-Protocol/Code/crates/mt-lottery/src/lib.rs @@ -89,6 +89,75 @@ impl CanonicalEncode for BundledConfirmation { } } +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum BcDecodeError { + Truncated, + HashCountOverflow, +} + +impl BundledConfirmation { + pub fn decode(input: &[u8]) -> Result<(Self, usize), BcDecodeError> { + // node_id 32 + endpoint 32 + window 8 + u16 op_count + op_count*32 + + // u16 reveal_count + reveal_count*32 + signature SIGNATURE_SIZE + let mut o = 0; + if input.len() < 32 + 32 + 8 + 2 { + return Err(BcDecodeError::Truncated); + } + let mut node_id = [0u8; 32]; + node_id.copy_from_slice(&input[o..o + 32]); + o += 32; + let mut endpoint = [0u8; 32]; + endpoint.copy_from_slice(&input[o..o + 32]); + o += 32; + let mut w = [0u8; 8]; + w.copy_from_slice(&input[o..o + 8]); + let window_index = u64::from_le_bytes(w); + o += 8; + let mut c = [0u8; 2]; + c.copy_from_slice(&input[o..o + 2]); + let op_count = u16::from_le_bytes(c) as usize; + o += 2; + if input.len() < o + op_count * 32 + 2 { + return Err(BcDecodeError::Truncated); + } + let mut op_hashes = Vec::with_capacity(op_count); + for _ in 0..op_count { + let mut h = [0u8; 32]; + h.copy_from_slice(&input[o..o + 32]); + op_hashes.push(h); + o += 32; + } + let mut rc = [0u8; 2]; + rc.copy_from_slice(&input[o..o + 2]); + let reveal_count = u16::from_le_bytes(rc) as usize; + o += 2; + if input.len() < o + reveal_count * 32 + SIGNATURE_SIZE { + return Err(BcDecodeError::Truncated); + } + let mut reveal_hashes = Vec::with_capacity(reveal_count); + for _ in 0..reveal_count { + let mut h = [0u8; 32]; + h.copy_from_slice(&input[o..o + 32]); + reveal_hashes.push(h); + o += 32; + } + let mut sig_bytes = [0u8; SIGNATURE_SIZE]; + sig_bytes.copy_from_slice(&input[o..o + SIGNATURE_SIZE]); + o += SIGNATURE_SIZE; + Ok(( + BundledConfirmation { + node_id, + endpoint, + window_index, + op_hashes, + reveal_hashes, + signature: Signature::from_array(sig_bytes), + }, + o, + )) + } +} + // spec: Правило R2 — bundle_hash = SHA-256("mt-bundle" || signed_scope(bundle)) pub fn bundle_hash(bc: &BundledConfirmation) -> Hash32 { let mut scope = Vec::new(); @@ -1777,4 +1846,34 @@ mod tests { // Это эдж кейс — в реальности active=0 halt liveness, не consensus assert!(is_cemented(0, 0)); } + #[test] + fn bc_encode_decode_roundtrip() { + let bc = BundledConfirmation { + node_id: [0x11; 32], + endpoint: [0x22; 32], + window_index: 12345, + op_hashes: vec![[0xAA; 32], [0xBB; 32], [0xCC; 32]], + reveal_hashes: vec![[0xDD; 32]], + signature: Signature::from_array([0xEE; SIGNATURE_SIZE]), + }; + let mut buf = Vec::new(); + bc.encode(&mut buf); + let (decoded, consumed) = BundledConfirmation::decode(&buf).expect("decode ok"); + assert_eq!(consumed, buf.len()); + assert_eq!(decoded, bc); + } + + #[test] + fn bc_decode_truncated_returns_error() { + let mut buf = vec![0u8; 50]; + assert!(matches!( + BundledConfirmation::decode(&buf), + Err(BcDecodeError::Truncated) + )); + buf.clear(); + assert!(matches!( + BundledConfirmation::decode(&buf), + Err(BcDecodeError::Truncated) + )); + } } diff --git a/Montana-Protocol/Code/crates/mt-sync/src/client.rs b/Montana-Protocol/Code/crates/mt-sync/src/client.rs new file mode 100644 index 00000000..2e83d0a7 --- /dev/null +++ b/Montana-Protocol/Code/crates/mt-sync/src/client.rs @@ -0,0 +1,311 @@ +//! FastSync client — receiver-side reassembly and verification. +//! +//! A follower joining a long-running mesh assembles the snapshot streamed by a +//! peer, reconstructs the Sparse Merkle `state_root`, and accepts it only if +//! that root byte-equals one of the cemented bootstrap `state_root`s the +//! follower has independently observed via Proposal propagation. The canonical +//! proposer advances every window, so the snapshot a peer streams reflects its +//! current head rather than the window the follower requested; matching the +//! reconstructed root against the set of recently observed bootstrap roots +//! keeps the integrity gate robust to that skew while still rejecting any +//! forged state for which the follower holds no bootstrap Proposal. + +use crate::response::FastSyncChunk; +use crate::snapshot::{Hash32, Snapshot, SnapshotError, TypedTables}; +use mt_state::compute_state_root; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum FastSyncClientError { + TotalChunksZero, + TotalChunksMismatch { expected: u32, actual: u32 }, + ChunkIndexOutOfRange { index: u32, total: u32 }, + DuplicateChunk { index: u32 }, + Record(SnapshotError), + Incomplete { received: u32, total: u32 }, + Build(SnapshotError), + StateRootUnmatched, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum AcceptOutcome { + Progress { received: u32, total: u32 }, + Complete, +} + +pub struct FastSyncClient { + total_chunks: Option, + received: BTreeSet, + snapshot: Snapshot, +} + +impl Default for FastSyncClient { + fn default() -> Self { + Self::new() + } +} + +impl FastSyncClient { + pub fn new() -> Self { + FastSyncClient { + total_chunks: None, + received: BTreeSet::new(), + snapshot: Snapshot::new(0), + } + } + + pub fn accept_chunk( + &mut self, + chunk: FastSyncChunk, + ) -> Result { + if chunk.total_chunks == 0 { + return Err(FastSyncClientError::TotalChunksZero); + } + match self.total_chunks { + None => self.total_chunks = Some(chunk.total_chunks), + Some(t) if t != chunk.total_chunks => { + return Err(FastSyncClientError::TotalChunksMismatch { + expected: t, + actual: chunk.total_chunks, + }); + }, + Some(_) => {}, + } + let total = chunk.total_chunks; + if chunk.chunk_index >= total { + return Err(FastSyncClientError::ChunkIndexOutOfRange { + index: chunk.chunk_index, + total, + }); + } + if self.received.contains(&chunk.chunk_index) { + return Err(FastSyncClientError::DuplicateChunk { + index: chunk.chunk_index, + }); + } + for rec in chunk.records { + self.snapshot + .add_record(chunk.table_id, rec) + .map_err(FastSyncClientError::Record)?; + } + self.received.insert(chunk.chunk_index); + if self.received.len() as u32 == total { + Ok(AcceptOutcome::Complete) + } else { + Ok(AcceptOutcome::Progress { + received: self.received.len() as u32, + total, + }) + } + } + + pub fn is_complete(&self) -> bool { + matches!(self.total_chunks, Some(t) if self.received.len() as u32 == t) + } + + pub fn finalize( + self, + recent_roots: &BTreeMap, + ) -> Result<(u64, TypedTables), FastSyncClientError> { + let total = self.total_chunks.unwrap_or(0); + let received = self.received.len() as u32; + if total == 0 || received != total { + return Err(FastSyncClientError::Incomplete { received, total }); + } + let tables = self + .snapshot + .build_tables() + .map_err(FastSyncClientError::Build)?; + let root = compute_state_root( + &tables.nodes.root(), + &tables.candidates.root(), + &tables.accounts.root(), + ); + match recent_roots.iter().find(|(_, r)| **r == root) { + Some((&window, _)) => Ok((window, tables)), + None => Err(FastSyncClientError::StateRootUnmatched), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::response::FastSyncTableId; + use mt_codec::CanonicalEncode; + use mt_crypto::PUBLIC_KEY_SIZE; + use mt_state::{AccountRecord, ACCOUNT_RECORD_SIZE}; + + fn acct_bytes(seed: u8) -> Vec { + let rec = AccountRecord { + account_id: [seed; 32], + balance: 1_000u128.wrapping_add(seed as u128), + suite_id: 1, + is_node_operator: seed % 2 == 0, + frontier_hash: [seed; 32], + op_height: seed as u32, + account_chain_length: seed as u32, + account_chain_length_snapshot: seed as u32, + current_pubkey: [seed; PUBLIC_KEY_SIZE], + creation_window: 0, + last_op_window: 0, + last_activation_window: 0, + }; + let mut buf = Vec::with_capacity(ACCOUNT_RECORD_SIZE); + rec.encode(&mut buf); + buf + } + + fn root_of(records: &[Vec]) -> Hash32 { + let mut s = Snapshot::new(0); + for r in records { + s.add_record(FastSyncTableId::Account, r.clone()).unwrap(); + } + let t = s.build_tables().unwrap(); + compute_state_root(&t.nodes.root(), &t.candidates.root(), &t.accounts.root()) + } + + fn roots_map(window: u64, root: Hash32) -> BTreeMap { + let mut m = BTreeMap::new(); + m.insert(window, root); + m + } + + fn chunk(idx: u32, total: u32, recs: Vec>) -> FastSyncChunk { + FastSyncChunk { + chunk_index: idx, + total_chunks: total, + table_id: FastSyncTableId::Account, + records: recs, + } + } + + #[test] + fn single_chunk_verifies_and_returns_matched_window() { + let recs = vec![acct_bytes(0x11), acct_bytes(0x22)]; + let root = root_of(&recs); + let mut c = FastSyncClient::new(); + assert_eq!( + c.accept_chunk(chunk(0, 1, recs)).unwrap(), + AcceptOutcome::Complete + ); + let (window, tables) = c.finalize(&roots_map(75_850, root)).expect("finalize"); + assert_eq!(window, 75_850); + assert_eq!(tables.accounts.len(), 2); + } + + #[test] + fn multi_chunk_out_of_order_verifies() { + let r0 = acct_bytes(0x01); + let r1 = acct_bytes(0x02); + let r2 = acct_bytes(0x03); + let root = root_of(&[r0.clone(), r1.clone(), r2.clone()]); + let mut c = FastSyncClient::new(); + assert!(matches!( + c.accept_chunk(chunk(2, 3, vec![r2])).unwrap(), + AcceptOutcome::Progress { .. } + )); + assert!(matches!( + c.accept_chunk(chunk(0, 3, vec![r0])).unwrap(), + AcceptOutcome::Progress { .. } + )); + assert_eq!( + c.accept_chunk(chunk(1, 3, vec![r1])).unwrap(), + AcceptOutcome::Complete + ); + let (window, tables) = c.finalize(&roots_map(9, root)).expect("finalize"); + assert_eq!(window, 9); + assert_eq!(tables.accounts.len(), 3); + } + + #[test] + fn matches_any_root_in_recent_set() { + let recs = vec![acct_bytes(0x44)]; + let root = root_of(&recs); + let mut m = BTreeMap::new(); + m.insert(40u64, [0xAAu8; 32]); + m.insert(41u64, root); + m.insert(42u64, [0xBBu8; 32]); + let mut c = FastSyncClient::new(); + c.accept_chunk(chunk(0, 1, recs)).unwrap(); + let (window, _) = c.finalize(&m).expect("finalize"); + assert_eq!(window, 41); + } + + #[test] + fn tampered_record_unmatched() { + let recs = vec![acct_bytes(0x11), acct_bytes(0x22)]; + let root = root_of(&recs); + let mut bad = recs.clone(); + bad[0][0] ^= 0xFF; + let mut c = FastSyncClient::new(); + c.accept_chunk(chunk(0, 1, bad)).unwrap(); + let err = c.finalize(&roots_map(1, root)).err().unwrap(); + assert_eq!(err, FastSyncClientError::StateRootUnmatched); + } + + #[test] + fn empty_recent_set_unmatched() { + let recs = vec![acct_bytes(0x11)]; + let mut c = FastSyncClient::new(); + c.accept_chunk(chunk(0, 1, recs)).unwrap(); + let err = c.finalize(&BTreeMap::new()).err().unwrap(); + assert_eq!(err, FastSyncClientError::StateRootUnmatched); + } + + #[test] + fn duplicate_chunk_rejected() { + let mut c = FastSyncClient::new(); + c.accept_chunk(chunk(0, 2, vec![acct_bytes(1)])).unwrap(); + let err = c + .accept_chunk(chunk(0, 2, vec![acct_bytes(9)])) + .unwrap_err(); + assert!(matches!( + err, + FastSyncClientError::DuplicateChunk { index: 0 } + )); + } + + #[test] + fn total_chunks_mismatch_rejected() { + let mut c = FastSyncClient::new(); + c.accept_chunk(chunk(0, 3, vec![acct_bytes(1)])).unwrap(); + let err = c + .accept_chunk(chunk(1, 4, vec![acct_bytes(2)])) + .unwrap_err(); + assert!(matches!( + err, + FastSyncClientError::TotalChunksMismatch { + expected: 3, + actual: 4 + } + )); + } + + #[test] + fn chunk_index_out_of_range_rejected() { + let mut c = FastSyncClient::new(); + let err = c + .accept_chunk(chunk(5, 3, vec![acct_bytes(1)])) + .unwrap_err(); + assert!(matches!( + err, + FastSyncClientError::ChunkIndexOutOfRange { index: 5, total: 3 } + )); + } + + #[test] + fn incomplete_finalize_rejected() { + let root = root_of(&[acct_bytes(1)]); + let mut c = FastSyncClient::new(); + c.accept_chunk(chunk(0, 2, vec![acct_bytes(1)])).unwrap(); + let err = c.finalize(&roots_map(0, root)).err().unwrap(); + assert!(matches!( + err, + FastSyncClientError::Incomplete { + received: 1, + total: 2 + } + )); + } +} diff --git a/Montana-Protocol/Code/crates/mt-sync/src/lib.rs b/Montana-Protocol/Code/crates/mt-sync/src/lib.rs index 61139110..42862b23 100644 --- a/Montana-Protocol/Code/crates/mt-sync/src/lib.rs +++ b/Montana-Protocol/Code/crates/mt-sync/src/lib.rs @@ -19,10 +19,12 @@ //! over the existing Noise_PQ XX session — bounded by network bandwidth, //! not by CPU iteration count. +pub mod client; pub mod request; pub mod response; pub mod snapshot; +pub use client::{AcceptOutcome, FastSyncClient, FastSyncClientError}; pub use request::FastSyncRequest; pub use response::{FastSyncChunk, FastSyncResponse, FastSyncTableId}; pub use snapshot::{Snapshot, SnapshotError, SnapshotVerifier}; diff --git a/Montana-Protocol/Code/crates/mt-vpn-balance/src/main.rs b/Montana-Protocol/Code/crates/mt-vpn-balance/src/main.rs index 35ce3eb8..7522af63 100644 --- a/Montana-Protocol/Code/crates/mt-vpn-balance/src/main.rs +++ b/Montana-Protocol/Code/crates/mt-vpn-balance/src/main.rs @@ -410,14 +410,19 @@ async fn handler_balance( } async fn handler_sub() -> impl IntoResponse { - let link = "vless://e6d355e2-2d79-4c96-a373-3b0e6b6f4b0d@cdn.montana.quest:443\ - ?flow=xtls-rprx-vision&type=tcp&headerType=none&security=reality\ - &fp=chrome&sni=www.googletagmanager.com\ - &pbk=EkTs2aGKnFNgFZ0f7wgft2sJp3VjwFQqIrwkZKM4gD8\ - &sid=302805bc0c25e504\ - #%C9%88%20%D0%9C%D0%BE%D0%BD%D1%82%D0%B0%D0%BD%D0%B0"; + let reality = "type=tcp&headerType=none&security=reality &fp=chrome&sni=www.googletagmanager.com &pbk=EkTs2aGKnFNgFZ0f7wgft2sJp3VjwFQqIrwkZKM4gD8 &sid=302805bc0c25e504"; + let entry = "entry.montana.quest:443"; + let links = [ + format!("vless://094f9073-aff0-4c07-a4af-6ca4c924f6a9@{entry}?{reality}#%F0%9F%87%AB%F0%9F%87%AE%20%D0%A5%D0%B5%D0%BB%D1%8C%D1%81%D0%B8%D0%BD%D0%BA%D0%B8"), + format!("vless://75e281f1-b702-5eb9-ba5c-8a5d38fa3c31@{entry}?{reality}#%F0%9F%87%A9%F0%9F%87%AA%20%D0%A4%D1%80%D0%B0%D0%BD%D0%BA%D1%84%D1%83%D1%80%D1%82"), + format!("vless://43ba0c0e-c1e3-4e30-8ae8-c2e68d24d7c7@{entry}?{reality}#%F0%9F%87%A6%F0%9F%87%B2%20%D0%95%D1%80%D0%B5%D0%B2%D0%B0%D0%BD"), + format!("vless://fc8a174d-f42b-4945-8548-ab5c9f448f81@{entry}?{reality}#%F0%9F%87%B1%F0%9F%87%B9%20%D0%92%D0%B8%D0%BB%D1%8C%D0%BD%D1%8E%D1%81"), + format!("vless://dad79315-0b80-5eca-9703-afee839e0131@{entry}?{reality}#%F0%9F%87%A8%F0%9F%87%BE%20%D0%9D%D0%B8%D0%BA%D0%BE%D1%81%D0%B8%D1%8F"), + format!("vless://e6d355e2-2d79-4c96-a373-3b0e6b6f4b0d@{entry}?flow=xtls-rprx-vision&{reality}#%C9%88%20%D0%9C%D0%BE%D0%BD%D1%82%D0%B0%D0%BD%D0%B0"), + ]; + let body = links.join("\n"); use base64::Engine; - let enc = base64::engine::general_purpose::STANDARD.encode(link.as_bytes()); + let enc = base64::engine::general_purpose::STANDARD.encode(body.as_bytes()); ( [ ("content-type", "text/plain; charset=utf-8"), diff --git a/Montana-Protocol/Code/docker/runtime/docker-compose.yml b/Montana-Protocol/Code/docker/runtime/docker-compose.yml index 1460b20e..98cc1182 100644 --- a/Montana-Protocol/Code/docker/runtime/docker-compose.yml +++ b/Montana-Protocol/Code/docker/runtime/docker-compose.yml @@ -33,6 +33,12 @@ services: container_name: montana-node restart: unless-stopped network_mode: host + environment: + - MONTANA_MNEMONIC=${MONTANA_MNEMONIC:-} + - MONTANA_GENESIS_MANIFEST_B64=${MONTANA_GENESIS_MANIFEST_B64:-} + - MONTANA_D_TEST_OVERRIDE=${MONTANA_D_TEST_OVERRIDE:-} + - MONTANA_LISTEN=${MONTANA_LISTEN:-/ip4/0.0.0.0/tcp/8444} + - MONTANA_FASTSYNC_LAG_THRESHOLD=${MONTANA_FASTSYNC_LAG_THRESHOLD:-} volumes: - montana-data:/var/lib/montana cpus: 1.0 diff --git a/Montana-Protocol/Code/docker/runtime/entrypoint.sh b/Montana-Protocol/Code/docker/runtime/entrypoint.sh index 627ea0b0..e7d1543b 100755 --- a/Montana-Protocol/Code/docker/runtime/entrypoint.sh +++ b/Montana-Protocol/Code/docker/runtime/entrypoint.sh @@ -3,8 +3,13 @@ # # Runs as root just long enough to make the named-volume mountpoint writable # by user montana, then drops privileges via runuser. On first start, the -# init step prints a 24-word mnemonic to stdout and saves it to mnemonic.txt -# inside the volume (mode 0400, owner montana). +# init step prints a 24-word mnemonic to stdout and saves it to mnemonic.txt. +# +# Network test mode (production binary, test parameters) via env: +# MONTANA_MNEMONIC fixed identity (so a test manifest can pre-list this node) +# MONTANA_GENESIS_MANIFEST_B64 base64 of a custom genesis manifest (test cohort) +# MONTANA_D_TEST_OVERRIDE small D → fast windows → fast admission VDF +# MONTANA_FASTSYNC_LAG_THRESHOLD lower lag threshold → observe fast-sync on a young chain set -eu @@ -13,26 +18,47 @@ MNEMONIC_FILE="$DATA_DIR/mnemonic.txt" MANIFEST="/etc/montana/genesis-manifest.json" LISTEN="${MONTANA_LISTEN:-/ip4/0.0.0.0/tcp/8444}" -# Make the named volume mountpoint writable by montana. chown -R montana:montana "$DATA_DIR" +# Test cohort: a supplied genesis manifest overrides the image-baked production one. +if [ -n "${MONTANA_GENESIS_MANIFEST_B64:-}" ]; then + echo "$MONTANA_GENESIS_MANIFEST_B64" | base64 -d > "$DATA_DIR/genesis-manifest.json" + chown montana:montana "$DATA_DIR/genesis-manifest.json" + MANIFEST="$DATA_DIR/genesis-manifest.json" + echo "[entrypoint] TEST MODE: using supplied genesis manifest" +fi + if [ ! -f "$DATA_DIR/identity.bin" ]; then echo "================================================================" echo " Montana node — first run on this volume" - echo " Generating identity. The 24 mnemonic words below are the ONLY" - echo " backup. Save them now (they will not be regenerated)." + echo " Generating identity. Save the 24 mnemonic words below." echo "================================================================" - runuser -u montana -- /usr/local/bin/montana-node init --data-dir "$DATA_DIR" \ - | tee "$MNEMONIC_FILE" + if [ -n "${MONTANA_MNEMONIC:-}" ]; then + runuser -u montana -- /usr/local/bin/montana-node init --data-dir "$DATA_DIR" \ + --mnemonic "$MONTANA_MNEMONIC" | tee "$MNEMONIC_FILE" + else + runuser -u montana -- /usr/local/bin/montana-node init --data-dir "$DATA_DIR" \ + | tee "$MNEMONIC_FILE" + fi chmod 0400 "$MNEMONIC_FILE" chown montana:montana "$MNEMONIC_FILE" - echo "================================================================" - echo " Mnemonic also saved to $MNEMONIC_FILE (mode 0400, owner montana)." - echo " Retrieve later with: docker exec montana-node cat $MNEMONIC_FILE" - echo "================================================================" + echo " Mnemonic saved to $MNEMONIC_FILE (mode 0400)." fi -exec runuser -u montana -- /usr/local/bin/montana-node start \ +DTEST="" +if [ -n "${MONTANA_D_TEST_OVERRIDE:-}" ]; then + DTEST="--d-test-override $MONTANA_D_TEST_OVERRIDE" + echo "[entrypoint] TEST MODE: $DTEST" +fi + +FASTSYNC_ENV="" +if [ -n "${MONTANA_FASTSYNC_LAG_THRESHOLD:-}" ]; then + FASTSYNC_ENV="MONTANA_FASTSYNC_LAG_THRESHOLD=$MONTANA_FASTSYNC_LAG_THRESHOLD" + echo "[entrypoint] fast-sync lag threshold override: $MONTANA_FASTSYNC_LAG_THRESHOLD" +fi + +exec runuser -u montana -- env $FASTSYNC_ENV /usr/local/bin/montana-node start \ --data-dir "$DATA_DIR" \ --listen "$LISTEN" \ - --genesis-manifest "$MANIFEST" + --genesis-manifest "$MANIFEST" \ + $DTEST diff --git a/Montana-Protocol/Manifesto/Manifesto EN.md b/Montana-Protocol/Manifesto/Manifesto EN.md new file mode 100644 index 00000000..5c378730 --- /dev/null +++ b/Montana-Protocol/Manifesto/Manifesto EN.md @@ -0,0 +1,81 @@ +# The Montana Manifesto + +**Version:** 1.0.0 +**Date:** 2026-05-28 +**Author:** Alejandro Montana +**Repository:** [github.com/efir369999/Montana](https://github.com/efir369999/Montana) + +> *"He who controls the past controls the future. He who controls the present controls the past."* +> — George Orwell, *1984* + +## I. The Question + +Bitcoin answered one question: **Whom do we trust with money?** *No one. Trust mathematics.* + +Bitcoin removed trust from money but left trust in time. Its difficulty adjusts to the wall-clocks of its miners; its block heights are measured against the watches of the world outside. + +Montana answers a deeper question: **Whom do we trust with time?** + +Money is a derivative of time, not the other way around. Today the infrastructure of time (NTP), of position (GPS), of communication (messaging servers) and of history (centralized databases) demands unconditional trust in a third party. One point of failure is one point of control. To control this infrastructure is to control the present. To control the databases is to rewrite the past. + +Montana makes *1984* technically impossible. + +## II. Time as Computation + +In Montana, a Verifiable Delay Function is not a clock that *displays* time. The VDF *is* time, written into the work of a sequential SHA-256 hash chain (FIPS 180-4). Each window is a sequential computation of `D ≈ 325 000 000` iterations on commodity x86_64 hardware. It cannot be parallelized; it cannot be faked; it cannot be hurried beyond the physics of the processor. + +Montana does not consume external time. Montana **produces** it. The output is an unbreakable cryptographic arrow of time — the **TimeChain**. + +We chose a sequential SHA-256 delay function over the efficiently-verifiable constructions of Boneh, Bonneau, Bünz and Fisch [CRYPTO 2018], Pietrzak [ITCS 2019] and Wesolowski [EUROCRYPT 2019] deliberately. Verification cost equals computation cost. The minimal cryptographic surface is its own audit. SHA-256 is already required for hashing, addressing and Merkle commitments; no new assumption is added. + +## III. The Hierarchy of Truth + +Montana is built on a strict dependency. Every layer is impossible without the one below. + +1. **Time** (`TimeChain`) — irreversible computation. The base layer of physics. Every operator ticks independently; together they form one global oscillator. +2. **Presence** (`NodeChain`) — proof that a specific identity accompanied this stream of time. Weight in the network is measured by proven time of presence, not by capital. Capital does not buy more time. +3. **Money** (`Account`, `TimeCoin`) — the quantitative derivative of proven presence. The unit `Ɉ` is not a reward for solving meaningless puzzles; it is the recording of a passed second in the network's ledger. Emission is closed-form: `supply(W) = 13 × (W + 1) Ɉ`. No premine. No presale. No founder allocation. +4. **History** (`Anchor`) — the binding of any external fact (document, message, transaction) to this protected timeline. A hash is sealed in the TimeChain. To rewrite it is to recompute every iteration of the VDF from genesis. Mathematically impossible. + +*Money without proven presence is a phantom. Presence without verifiable time is a claim. Time without irreversible computation is trust.* + +## IV. Post-Quantum from the First Day + +All consensus signatures are **ML-DSA-65** (FIPS 204). All transport key encapsulation is **ML-KEM-768** (FIPS 203). Hashing is **SHA-256** (FIPS 180-4). The transport handshake is **Noise_PQ XX**: ephemeral ML-KEM-768 on both sides, an ML-DSA-65 signature binding the full handshake transcript, and ChaCha20-Poly1305 AEAD framing on the established session (RFC 8439). + +No ECDSA. No EdDSA. No classical Diffie-Hellman. No assumption that Shor's algorithm will be late. + +PeerId is the SHA-256 multihash of each peer's ML-DSA-65 identity public key. Routing identity and consensus identity are bound to the same key material. + +## V. Architecture Without Compromise + +- **Zero fees.** Anti-spam is operated by time, not by money: per-identity rate per window, `account_chain_length` thresholds, seniority gating. The protocol contains no `fee` field on any operation. +- **Asynchronous finality.** Transfers do not wait for blocks. They are cemented through a P2P quorum of signatures from active operators in approximately 300 milliseconds. +- **No plutocracy.** Whoever holds one billion `Ɉ` has no more power in consensus than the operator of a Mac Mini. Emission (chronometric) and consensus (Proof of Time) are mathematically separated. The lottery seed incorporates `cemented_bundle_aggregate(W-2)` — a value an attacker cannot precompute without forging the signatures of honest participants. +- **No governance in state.** There is no DAO, no treasury, no founder veto. Advisory councils may exist outside the protocol; none of them have binding force inside it. The author is removed from the protocol. +- **No genesis nodes.** Montana launches as a peer-to-peer network in the style of Bitcoin. Any participant joins by running one command in a terminal. There is no founder-controlled bootstrap quorum. +- **67% honest active chain length.** Safety holds while honest operators control more than two-thirds of `active_chain_length`. Capital does not enter this threshold. + +## VI. The Scale Baseline + +Every decision in Montana is calibrated for **at least one billion active users**. Mechanisms that do not scale to 10⁹ are discarded without discussion. AccountRecord is 2 059 bytes; state at 10⁹ accounts is approximately 2.06 TB, holdable on commodity disks. The pruning rule is canonical: state size is bounded by active population, not by chain age. + +## VII. Privacy as a Choice + +Balances, transfers and operator identities are public by default. Privacy is achieved through **Anchor** objects: a 32-byte hash is committed to the chain and the encrypted content is held off-chain by its owner. The protocol has no visibility into the contents. Privacy is what the user chooses to keep — not what the protocol imposes nor what the protocol forbids. + +## VIII. What Montana Is Not + +Montana is not a faster Ethereum. Montana is not an L2. Montana is not a privacy mixer. Montana is not yield. Montana is not governance. Montana is not a brand. + +Montana is the digital atomic clock for the internet. It is the standard of frequency from which money, presence and history derive. + +--- + +**Reference implementation:** Rust, Apache-2.0 / MIT. Twelve crates including `mt-timechain`, `mt-consensus`, `mt-lottery`, `mt-crypto`, `mt-net`, `mt-noise-pq`. Specification: [Whitepaper Montana.md](../Whitepaper%20Montana.md). + +**Symbol:** **Ɉ** — one second of Montana time. + +Alejandro Montana +*Ничто_Nothing_无_金元Ɉ* +2026-05-28 diff --git a/Montana-Protocol/Manifesto/Manifesto RU.md b/Montana-Protocol/Manifesto/Manifesto RU.md new file mode 100644 index 00000000..cd897d5c --- /dev/null +++ b/Montana-Protocol/Manifesto/Manifesto RU.md @@ -0,0 +1,81 @@ +# Манифест Монтаны + +**Версия:** 1.0.0 +**Дата:** 28 мая 2026 +**Автор:** Alejandro Montana +**Репозиторий:** [github.com/efir369999/Montana](https://github.com/efir369999/Montana) + +> *«Тот, кто контролирует прошлое, контролирует будущее. Тот, кто контролирует настоящее, контролирует прошлое».* +> — Дж. Оруэлл, *1984* + +## I. Вопрос + +Биткоин ответил на один вопрос: **Кому доверять деньги?** *Никому. Доверяй математике.* + +Биткоин убрал доверие из денег, но оставил доверие во времени. Его сложность подстраивается под наручные часы майнеров; высота его блоков сверяется со временем мира снаружи. + +Монтана отвечает на более глубокий вопрос: **Кому доверять время?** + +Деньги — производная от времени, а не наоборот. Сегодня инфраструктура измерения времени (NTP), позиционирования (GPS), связи (серверы мессенджеров) и истории (централизованные базы данных) требует безусловного доверия к третьей стороне. Одна точка отказа — одна точка контроля. Контролировать эту инфраструктуру означает контролировать настоящее. Контролировать базы данных — переписывать прошлое. + +Монтана делает *1984* технически невозможным. + +## II. Время как вычисление + +В Монтане функция отложенной верификации (VDF) — это не часы, которые *показывают* время. VDF — это само время, *записанное в работе* последовательной цепочки хэшей SHA-256 (FIPS 180-4). Каждое окно — последовательное вычисление `D ≈ 325 000 000` итераций на обычном процессоре x86_64. Его невозможно распараллелить, нельзя подделать, нельзя ускорить за пределами физики самого процессора. + +Монтана не потребляет внешнее время. Монтана его **производит**. На выходе — нерушимая криптографическая стрела времени — **TimeChain**. + +Мы намеренно выбрали последовательную SHA-256 функцию вместо эффективно-верифицируемых конструкций Бонеха-Бонно-Бюнца-Фиша [CRYPTO 2018], Петрчака [ITCS 2019] и Весоловского [EUROCRYPT 2019]. Стоимость проверки равна стоимости вычисления. Минимальная криптографическая поверхность — это и есть аудит. SHA-256 в любом случае нужен для хеширования, адресации и обязательств Меркла; новых предположений не добавляется. + +## III. Иерархия истины + +Архитектура Монтаны строится на строгой зависимости. Каждый последующий слой невозможен без предыдущего. + +1. **Время** (`TimeChain`) — необратимое вычисление. Базовый слой физики. Каждый оператор тикает независимо; вместе они образуют единый глобальный осциллятор. +2. **Присутствие** (`NodeChain`) — доказательство того, что конкретный идентификатор сопровождал этот поток времени. Вес в сети измеряется доказанным временем присутствия, не капиталом. Капитал не покупает времени. +3. **Деньги** (`Account`, `TimeCoin`) — количественная производная от доказанного присутствия. Единица `Ɉ` — это не награда за решение бессмысленных задач, а запись прошедшей секунды в книге сети. Эмиссия в закрытой форме: `supply(W) = 13 × (W + 1) Ɉ`. Никакого премайна. Никакого пресейла. Никакой доли основателя. +4. **История** (`Anchor`) — привязка любого внешнего факта (документа, сообщения, транзакции) к этой защищённой временной линии. Хэш навсегда зафиксирован в TimeChain. Переписать его означает пересчитать каждую итерацию VDF от генезиса. Математически невозможно. + +*Деньги без доказанного присутствия — фантомы. Присутствие без верифицируемого времени — заявление. Время без необратимых вычислений — доверие.* + +## IV. Постквантовая с первого дня + +Все консенсусные подписи — **ML-DSA-65** (FIPS 204). Все инкапсуляции ключей транспорта — **ML-KEM-768** (FIPS 203). Хеширование — **SHA-256** (FIPS 180-4). Транспортное рукопожатие — **Noise_PQ XX**: эфемерные ML-KEM-768 с обеих сторон, подпись ML-DSA-65 связывающая весь транскрипт рукопожатия, шифрование установленной сессии ChaCha20-Poly1305 AEAD (RFC 8439). + +Никакого ECDSA. Никакого EdDSA. Никакого классического Диффи-Хеллмана. Никаких надежд, что алгоритм Шора задержится. + +PeerId — это SHA-256 multihash открытого ключа ML-DSA-65 каждого пира. Маршрутизация и консенсус привязаны к одному и тому же ключевому материалу. + +## V. Архитектура без компромиссов + +- **Без комиссий.** Защита от спама построена через время, не через деньги: одна операция на идентичность за окно, пороги по `account_chain_length`, приоритет по выслуге. В протоколе нет поля `fee` ни в одной операции. +- **Асинхронная финальность.** Переводы не ждут блоков. Они закрепляются через P2P-кворум подписей активных операторов примерно за 300 миллисекунд. +- **Без плутократии.** Тот, у кого один миллиард `Ɉ`, не имеет в консенсусе больше власти, чем оператор Mac Mini. Эмиссия (хронометрическая) и консенсус (Proof of Time) математически разделены. Семя лотереи содержит `cemented_bundle_aggregate(W-2)` — значение, которое атакующий не может предвычислить без подделки подписей честных участников. +- **Никакого governance в состоянии.** Нет DAO, нет казны, нет права вето основателя. Совещательные советы могут существовать снаружи протокола; ни один из них не имеет обязывающей силы внутри. Автор удалён из протокола. +- **Никаких узлов генезиса.** Монтана запускается как peer-to-peer-сеть в стиле Биткоина. Любой участник присоединяется одной командой в терминале. Bootstrap-кворума под контролем основателя не существует. +- **67 % честной active_chain_length.** Безопасность сохраняется, пока честные операторы контролируют более двух третей `active_chain_length`. Капитал в этот порог не входит. + +## VI. Базовый масштаб + +Каждое решение Монтаны рассчитывается под **не менее одного миллиарда активных пользователей**. Механизмы, не масштабирующиеся на 10⁹, отбрасываются без обсуждения. Запись аккаунта — 2 059 байт; состояние при 10⁹ аккаунтов — около 2,06 ТБ, помещается на обычных дисках. Правило очистки каноническое: размер состояния ограничен активным населением, не возрастом цепи. + +## VII. Приватность как выбор + +Балансы, переводы и идентификаторы операторов открыты по умолчанию. Приватность реализуется через объекты **Anchor**: 32-байтный хэш записывается в цепь, а зашифрованное содержимое хранится у владельца вне цепи. Протокол не видит содержимого. Приватность — это то, что пользователь выбирает сохранить, а не то, что протокол навязывает или запрещает. + +## VIII. Чем Монтана не является + +Монтана — не более быстрый Эфириум. Монтана — не L2. Монтана — не приватный миксер. Монтана — не доходность. Монтана — не governance. Монтана — не бренд. + +Монтана — это цифровые атомные часы интернета. Это эталон частоты, от которого выводятся деньги, присутствие и история. + +--- + +**Эталонная реализация:** Rust, Apache-2.0 / MIT. Двенадцать крейтов, включая `mt-timechain`, `mt-consensus`, `mt-lottery`, `mt-crypto`, `mt-net`, `mt-noise-pq`. Спецификация: [Whitepaper Montana.md](../Whitepaper%20Montana.md). + +**Символ:** **Ɉ** — одна секунда времени Монтаны. + +Alejandro Montana +*Ничто_Nothing_无_金元Ɉ* +2026-05-28 diff --git a/Montana-Protocol/Manifesto/Manifesto ZH.md b/Montana-Protocol/Manifesto/Manifesto ZH.md new file mode 100644 index 00000000..9ac8f908 --- /dev/null +++ b/Montana-Protocol/Manifesto/Manifesto ZH.md @@ -0,0 +1,81 @@ +# 蒙塔纳宣言 + +**版本:** 1.0.0 +**日期:** 2026 年 5 月 28 日 +**作者:** Alejandro Montana +**仓库:** [github.com/efir369999/Montana](https://github.com/efir369999/Montana) + +> *「谁控制过去,谁就控制未来;谁控制现在,谁就控制过去。」* +> ——乔治·奥威尔《1984》 + +## 一、问题 + +比特币回答了一个问题:**谁来托管金钱?** *无人。请信任数学。* + +比特币把信任从金钱中移除,却把信任留在了时间里。它的难度调整依赖于矿工的钟表,它的区块高度以外界的时钟为度量。 + +蒙塔纳回答一个更深的问题:**谁来托管时间?** + +金钱是时间的衍生物,反之则不然。今天,测量时间的基础设施(NTP)、定位的基础设施(GPS)、通讯的基础设施(消息服务器)以及历史的基础设施(中心化数据库),都要求无条件信任第三方。一个故障点,就是一个控制点。控制这套基础设施,就是控制现在;控制这些数据库,就是改写过去。 + +蒙塔纳让《1984》在技术上不可能成立。 + +## 二、时间即计算 + +在蒙塔纳中,可验证延迟函数(VDF)不是一个 *显示* 时间的时钟。VDF 本身 *就是* 时间,写在 SHA-256(FIPS 180-4)顺序哈希链的工作之中。每一个时间窗口是约 `D ≈ 325 000 000` 次顺序迭代的计算,运行于普通 x86_64 处理器之上。它无法并行,无法伪造,也无法在处理器物理极限之外加速。 + +蒙塔纳不消耗外部时间,而是 **生产** 时间。其输出,是一支不可摧毁的密码学时间之箭——**TimeChain**。 + +我们刻意选用顺序 SHA-256 延迟函数,而非 Boneh-Bonneau-Bünz-Fisch [CRYPTO 2018]、Pietrzak [ITCS 2019]、Wesolowski [EUROCRYPT 2019] 的可高效验证型 VDF。验证成本等于计算成本。最小的密码学表面,本身就是审计。SHA-256 在哈希、寻址与默克尔承诺中本就必须存在,并未引入任何新的假设。 + +## 三、真相的层级 + +蒙塔纳的架构建立在严格的依赖之上,每一层都不可能脱离其下一层而成立。 + +1. **时间**(`TimeChain`)——不可逆的计算。物理学的基础层。每个运营者独立地嘀嗒,共同构成一个全球振荡器。 +2. **在场**(`NodeChain`)——证明某个特定身份伴随了这一时间流。网络中的权重,由可证明的在场时间度量,而非由资本度量。资本不能购买时间。 +3. **货币**(`Account` 与 `TimeCoin`)——可证在场的数量化衍生物。单位 `Ɉ` 不是解决无意义难题的奖励,而是网络账本中一秒钟的记录。发行量是封闭式的:`supply(W) = 13 × (W + 1) Ɉ`。没有预挖。没有预售。没有创始人份额。 +4. **历史**(`Anchor`)——把任何外部事实(文档、消息、交易)绑定到这条受保护的时间线。其哈希被永久封存于 TimeChain 之中。改写它,意味着从创世起重新计算每一次 VDF 迭代。在数学上不可能。 + +*没有可证在场的货币,是幻影;没有可验证时间的在场,是断言;没有不可逆计算的时间,是信任。* + +## 四、自第一天起的抗量子 + +所有共识签名采用 **ML-DSA-65**(FIPS 204)。所有传输层密钥封装采用 **ML-KEM-768**(FIPS 203)。哈希采用 **SHA-256**(FIPS 180-4)。传输握手为 **Noise_PQ XX**:双方各自的临时 ML-KEM-768、绑定到完整握手记录的 ML-DSA-65 签名、以及在已建立会话上的 ChaCha20-Poly1305 AEAD 帧加密(RFC 8439)。 + +没有 ECDSA。没有 EdDSA。没有经典 Diffie-Hellman。不指望 Shor 算法会迟到。 + +PeerId 是每个节点 ML-DSA-65 身份公钥的 SHA-256 multihash。路由身份与共识身份绑定在同一份密钥之上。 + +## 五、不妥协的架构 + +- **零手续费。** 防垃圾通过时间而非金钱实现:每身份每窗口一次操作、`account_chain_length` 阈值、资历门控。协议中任何操作都没有 `fee` 字段。 +- **异步终局。** 转账不等待区块,而是由活跃运营者的 P2P 法定签名在约 300 毫秒内固化。 +- **不行金权政治。** 持有十亿 `Ɉ` 的人,在共识中并不比一台 Mac Mini 的运营者拥有更多权力。发行(按时间计量)与共识(时间证明)在数学上彼此分离。抽签种子包含 `cemented_bundle_aggregate(W-2)`——攻击者若不伪造诚实参与者的签名便无法预计算的值。 +- **状态中无治理。** 没有 DAO,没有金库,没有创始人否决权。咨询委员会可在协议之外存在;它们在协议之内皆无约束力。作者将自己从协议中移除。 +- **不设创世节点。** 蒙塔纳以比特币式的对等网络方式启动。任何参与者皆可通过终端中的一条命令加入。不存在由创始人掌控的引导法定人数。 +- **诚实 `active_chain_length` 的三分之二。** 只要诚实运营者掌握 `active_chain_length` 的三分之二以上,安全性即得到保持。资本不进入这一门槛。 + +## 六、规模基准 + +蒙塔纳的每一项决策都以 **至少十亿活跃用户** 为基准。无法扩展到 10⁹ 的机制不予讨论。账户记录为 2 059 字节;10⁹ 账户下的状态约 2.06 TB,普通磁盘即可承载。修剪规则是规范的:状态大小由活跃人口决定,而非由链龄决定。 + +## 七、隐私即选择 + +余额、转账与运营者身份默认公开。隐私通过 **Anchor** 对象实现:32 字节的哈希被提交到链上,加密内容由所有者保存在链外。协议看不见内容。隐私是用户选择保留的,而不是协议强加或禁止的。 + +## 八、蒙塔纳不是什么 + +蒙塔纳不是更快的以太坊。蒙塔纳不是 L2。蒙塔纳不是隐私混币。蒙塔纳不是收益率。蒙塔纳不是治理。蒙塔纳不是品牌。 + +蒙塔纳是互联网的数字原子钟。它是频率的基准,金钱、在场与历史皆由此衍生。 + +--- + +**参考实现:** Rust,Apache-2.0 / MIT。十二个 crate,包括 `mt-timechain`、`mt-consensus`、`mt-lottery`、`mt-crypto`、`mt-net`、`mt-noise-pq`。规范:[Whitepaper Montana.md](../Whitepaper%20Montana.md)。 + +**符号:** **Ɉ**——蒙塔纳时间的一秒。 + +Alejandro Montana +*Ничто_Nothing_无_金元Ɉ* +2026-05-28 diff --git a/Montana-Protocol/Manifesto/README.md b/Montana-Protocol/Manifesto/README.md new file mode 100644 index 00000000..352778d7 --- /dev/null +++ b/Montana-Protocol/Manifesto/README.md @@ -0,0 +1,20 @@ +# Montana Manifesto + +**Version:** 1.0.0 +**Date:** 2026-05-28 +**Author:** Alejandro Montana +**Repository:** [github.com/efir369999/Montana](https://github.com/efir369999/Montana) + +A single declaration of what Montana is and refuses to be, published in three languages from one canonical version. The three texts say the same thing. + +- [English](Manifesto%20EN.md) — for the Metzdowd Cryptography mailing list and independent reviewers +- [Русский](Manifesto%20RU.md) — голос автора +- [中文](Manifesto%20ZH.md) — 中文版本 + +The English version is canonical for cryptographic claims; the Russian version is canonical for the author's voice. For the academic specification of the protocol, see [Whitepaper Montana.md](../Whitepaper%20Montana.md). + +--- + +**Symbol:** **Ɉ** — one second of Montana time. + +Alejandro Montana diff --git a/Montana-Protocol/Montana Network v1.2.0.md b/Montana-Protocol/Montana Network v1.3.0.md similarity index 99% rename from Montana-Protocol/Montana Network v1.2.0.md rename to Montana-Protocol/Montana Network v1.3.0.md index a425c2aa..037130d8 100644 --- a/Montana-Protocol/Montana Network v1.2.0.md +++ b/Montana-Protocol/Montana Network v1.3.0.md @@ -1,6 +1,6 @@ # Montana — Network Layer Specification -**Version:** 1.2.0 (2026-05-26) +**Version:** 1.3.0 (2026-05-27) **Layer:** Network — sits between Protocol (low) and App (high). @@ -195,7 +195,7 @@ Wire-format and KAT vectors for the cemented Proposal envelope with `bundle_coun - **Phase 2 — XK → XX redesign (closed).** The initial XK variant required the initiator to know the responder's static ML-KEM-768 public key a priori — incompatible with libp2p's plug-in `with_tcp` auth-upgrade slot which gives the upgrade only the local `libp2p::identity::Keypair` (Ed25519). The XX redesign discovers remote identity during the handshake (ephemeral ML-KEM-768 keypairs on both sides; identity ML-DSA-65 pk transmitted in msg2 / msg3 and authenticated by signature over transcript). New wire format documented in this section. - **Phase 3 — Classical removal (closed).** The libp2p auth chain `(tls::Config::new, noise::Config::new)` in `mt-net-transport::transport::build_swarm_with_keypair` was replaced with `NoisePqXxConfig`. The transport stack is now `TCP → Noise_PQ XX → Yamux`. Uniform framing layer is preserved (it provides DPI obfuscation orthogonally to the handshake). The `pq_transport_version` field stays reserved-but-unused for future protocol negotiation if multistream-select proves insufficient. -**Verification on the genesis 3-node network.** Each phase is verified on the three production nodes (Moscow, Helsinki, Frankfurt) for ≥24 hours of continuous operation before being declared closed. Phase 1 closure requires byte-exact KAT vectors checked into `mt-conformance` and cross-node handshake success with zero classical fallback during the observation window. +**Verification on the production network.** Each phase is verified on the production network for ≥24 hours of continuous operation before being declared closed. Phase 1 closure requires byte-exact KAT vectors checked into `mt-conformance` and cross-node handshake success with zero classical fallback during the observation window. **[I-1] compliance status.** Closed. The entire protocol stack is post-quantum end-to-end: consensus signatures via ML-DSA-65, application-layer encryption via ML-KEM-768, transport handshake via Noise_PQ XX (ML-KEM-768 + ML-DSA-65). No classical Diffie-Hellman remains in the protocol layer. @@ -459,6 +459,17 @@ A single reported observation does not move the map: a `(vantage_class, target_r Reachability sensing, the map, and steering are local network-stack behaviour on the node's own clock and sit outside the scope of consensus state. +#### Gateway entry-point failover + +Where clients reach the network through a managed gateway rather than by direct peer connection, the same reachability discipline governs the gateway tier. A client holds a single stable entry identifier; the network binds that identifier to the currently reachable gateway and rebinds it on failure, so the client never reconfigures. + +An ordered set of gateway fronts backs the identifier. Election is deterministic: the highest-priority front whose transport handshake currently succeeds owns the identifier. Liveness is decided by a completed transport handshake of the deployed obfuscation profile, not by a bare TCP or ICMP probe — a port that accepts a connection but does not complete the handshake is not a live gateway, so an unrelated service answering on the same port is never mistaken for a working front. A front is demoted only after a corroborated run of failed handshakes (hysteresis) and reclaims its priority on recovery; rebinding moves the same identifier between fronts that present identical client-facing transport parameters, so sessions migrate with no client-visible change. + +External exposure is minimized as a censorship countermeasure. Only the currently bound entry is externally resolvable; reserve fronts and exit relays are not enumerable from outside and appear at the entry identifier only when promoted. An adversary who resolves the identifier learns exactly one address — blocking it triggers rebinding to a reserve for which the adversary holds no prior address, rather than collapsing a pre-enumerated fleet. Exit relays are reached only through a front and are never client-facing, so the client-visible surface is one address at a time regardless of fleet size. + +By preference the bound entry is a single address reachable inside the client's own jurisdiction: a domestic first hop minimizes the connection signature and relocates the cross-border segment to the gateway-to-exit link, which carries datacenter-grade capacity under the same handshake obfuscation. The gateway tier is deployment infrastructure: it enters no state root and forms no consensus state. + + ### Dandelion++ (sender anonymity) Montana's P2P gossip retransmits operations through all nodes. Without protection, the first peer knows the sender's IP. Dandelion++ (Fanti et al. 2018) breaks the IP → operation link by modifying the existing gossip. diff --git a/Montana-Protocol/Montana Protocol v35.25.1.md b/Montana-Protocol/Montana Protocol v35.25.1.md index abd99012..8c3eee1d 100644 --- a/Montana-Protocol/Montana Protocol v35.25.1.md +++ b/Montana-Protocol/Montana Protocol v35.25.1.md @@ -2512,8 +2512,8 @@ Runtime коррекция учитывает фактическую длите | Hardware profile | Специфика | MH/s по локальному кварцу | |------------------|-----------|---------------------------| | **Genesis-железо** (iMac M1 2021, idle) | Apple M1, ARM SHA-2 hw ext, 8 GB | **5.097** (нормативный) | -| Idle VPS (Timeweb, Moscow) | QEMU Virtual CPU v4.2.0, 2.1 GHz, без hw SHA | ~3.68 | -| Loaded VPS (Timeweb, Frankfurt) | QEMU Virtual CPU v8.2.0 c SHA-NI, concurrent production сервисы на том же ядре | ~0.22 | +| Idle commodity VPS (x86_64, no hw SHA) | QEMU Virtual CPU v4.2.0, 2.1 GHz, без hw SHA | ~3.68 | +| Loaded commodity VPS (x86_64, SHA-NI) | QEMU Virtual CPU v8.2.0 c SHA-NI, concurrent production сервисы на том же ядре | ~0.22 | Comparative таблица иллюстрирует что hardware variance между классами достигает ×20+. Operator выбирает железо до запуска узла; недостаточная производительность означает participation_ratio < 0.85 → выпадение из active set через 8τ₂ inactivity pruning. diff --git a/Montana-Protocol/README.md b/Montana-Protocol/README.md index 60d72822..a90adab7 100644 --- a/Montana-Protocol/README.md +++ b/Montana-Protocol/README.md @@ -2,7 +2,6 @@ > Post-quantum reference blockchain. Sequential-delay TimeChain consensus over SHA-256. Time-as-scarcity instead of fees. > Production transport is **Noise_PQ XX** (ML-KEM-768 + ML-DSA-65 + ChaCha20-Poly1305). -> Live four-node mesh: Moscow, Frankfurt, Helsinki, Yerevan. > Mainnet **v0.2** spec package. Rust reference implementation `1.0.0`. Dual-licensed Apache-2.0 / MIT. > **First mainnet release:** [v1.0.0](https://github.com/efir369999/Montana/releases/tag/v1.0.0) (2026-05-22). @@ -13,7 +12,6 @@ Montana is a post-quantum sovereignty stack. Every primitive in the protocol layer is post-quantum: | Layer | Primitive | Standard | -|------|-----------|----------| | Consensus signatures | ML-DSA-65 | NIST FIPS 204 | | Application key encapsulation | ML-KEM-768 | NIST FIPS 203 | | Transport handshake | Noise_PQ XX (ML-KEM-768 + ML-DSA-65) | This project | @@ -54,9 +52,8 @@ The whitepaper covers, in present-tense factual form: The protocol is specified as three layered documents — each independently auditable: | Layer | Spec | Scope | -|-------|------|-------| | 1. Protocol | [`Montana Protocol v35.25.1.md`](Montana%20Protocol%20v35.25.1.md) | State machine, crypto primitives (ML-DSA-65, ML-KEM-768, SHA-256), sequential-delay TimeChain, lottery, Account / Node tables, Genesis Decree, `apply_proposal` pipeline, consensus operations | -| 2. Network | [`Montana Network v1.2.0.md`](Montana%20Network%20v1.2.0.md) | libp2p transport, Noise_PQ XX (production), Identity-Bound Tunnel, transport randomness, PeerRecord, mesh transport, sync protocols, network-layer threat model, KAT vectors | +| 2. Network | [`Montana Network v1.3.0.md`](Montana%20Network%20v1.3.0.md) | libp2p transport, Noise_PQ XX (production), Identity-Bound Tunnel, transport randomness, PeerRecord, mesh transport, sync protocols, network-layer threat model, KAT vectors | | 3. App | [`Montana App v3.12.0.md`](Montana%20App%20v3.12.0.md) | UI, wallet, messenger (Double Ratchet PQ), channels, contacts, profile, Junona AI agent, browser, premium, application-layer economy | | 4. Egress | [`Montana Egress v1.0.0.md`](Montana%20Egress%20v1.0.0.md) | clearnet egress over the mesh: entry/relay/exit roles, egress directory, manual/auto country selection, two-session architecture, exit policy, threat model | | 5. Alliance | [`Montana VPN Alliance v1.1.0.md`](Montana%20VPN%20Alliance%20v1.1.0.md) | federation pattern: universal-key membership, mutual reachability insurance, front-light/exit-heavy load model, resilience | @@ -67,15 +64,9 @@ Layer dependency direction: Protocol (low) ← Network (mid) ← App (high). Eac ## Live network -Three-node Genesis cohort, full 6/6 pairwise mesh over Noise_PQ XX (`/montana/noise-pq-xx/1.0.0`): +The reference implementation runs a live production mesh with full pairwise Noise_PQ XX sessions (`/montana/noise-pq-xx/1.0.0`). Node addresses, identities, and locations are not published; the network is reached through the censorship-resistant discovery channels defined in the Network specification, not a static list. -| Label | Region | XX PeerId (SHA-256 multihash of ML-DSA-65 pk) | -|-------|--------|-----------------------------------------------| -| moscow | Russia | `QmSDUqLkLcenkkNw6PUKYXjesEmaDksnrEaCzbs3a5nVzj` | -| frankfurt | Germany | `QmPFm5L3WiA47J66zVJvio23QBgBqr4nAqCP626vgEnHNP` | -| helsinki | Finland | `QmNSrA82XExjEXUS5xTPhn9MV55bfhYofxfcm7dTFcQPjL` | -Dashboard with 60-second auto-refresh: [efir.org/explorer/](https://efir.org/explorer/). Current snapshot: [`STATUS.md`](STATUS.md). --- @@ -106,7 +97,7 @@ This is a public invitation. Every primitive, every consensus rule, every byte o - **Deploy a node** on any Linux VPS — one command, approximately five minutes, approximately five gibibytes of disk, one gibibyte of RAM. See [`Code/AGENTS.md`](Code/AGENTS.md) → *Deploy*. - **Run stress / chaos / fuzz suites** against your node. See [`Code/AGENTS.md`](Code/AGENTS.md) → *Stress test*. -- **Audit the code against the spec.** [`Code/docs/SPEC_DEVIATIONS.md`](Code/docs/SPEC_DEVIATIONS.md) lists deviations, acknowledgments, and closures. The spec is the single source of truth: [`Montana Protocol v35.25.1.md`](Montana%20Protocol%20v35.25.1.md) + [`Montana Network v1.2.0.md`](Montana%20Network%20v1.2.0.md) + [`Montana App v3.12.0.md`](Montana%20App%20v3.12.0.md). +- **Audit the code against the spec.** [`Code/docs/SPEC_DEVIATIONS.md`](Code/docs/SPEC_DEVIATIONS.md) lists deviations, acknowledgments, and closures. The spec is the single source of truth: [`Montana Protocol v35.25.1.md`](Montana%20Protocol%20v35.25.1.md) + [`Montana Network v1.3.0.md`](Montana%20Network%20v1.3.0.md) + [`Montana App v3.12.0.md`](Montana%20App%20v3.12.0.md). - **Send findings** as GitHub Issues or Pull Requests. No NDA, no engagement contract. The protocol gets stronger or it does not ship. **What this is NOT:** @@ -145,7 +136,6 @@ The full installer prints a 24-word recovery mnemonic for the node and a VLESS U ## Status by milestone | Milestone | State | Tests | -|-----------|-------|-------| | M1 foundational primitives (mt-codec, mt-crypto, mt-crypto-native, mt-mnemonic) | ready | 100+ unit + 51 NIST KAT | | M2 state foundation (mt-merkle, mt-genesis, mt-state, mt-timechain) | ready | 95+ unit + 60 invariants | | M3 apply_proposal (mt-account) | ready | 89 unit + 29 invariants | @@ -161,10 +151,9 @@ The full installer prints a 24-word recovery mnemonic for the node and a VLESS U ## Repository layout | Path | Contents | -|------|----------| | [`Whitepaper Montana.md`](Whitepaper%20Montana.md) | Academic paper in the style of the Bitcoin paper. Metzdowd-list submission text | | [`Montana Protocol v35.25.1.md`](Montana%20Protocol%20v35.25.1.md) | Full protocol specification | -| [`Montana Network v1.2.0.md`](Montana%20Network%20v1.2.0.md) | Network-layer specification (Noise_PQ XX, IBT, mesh, sync) | +| [`Montana Network v1.3.0.md`](Montana%20Network%20v1.3.0.md) | Network-layer specification (Noise_PQ XX, IBT, mesh, sync) | | [`Montana App v3.12.0.md`](Montana%20App%20v3.12.0.md) | Client application specification | | [`External-Audit/`](External-Audit/) | First external security review and the project's disposition | | [`Code/`](Code/) | Rust workspace — 17 crates, 9 milestones | diff --git a/Montana-Protocol/STATUS.md b/Montana-Protocol/STATUS.md deleted file mode 100644 index e1b98bc9..00000000 --- a/Montana-Protocol/STATUS.md +++ /dev/null @@ -1,61 +0,0 @@ -# Montana Network — Live Status - -**Updated:** 2026-05-02T16:30:04Z UTC -**Live dashboard:** [efir.org/explorer/](https://efir.org/explorer/) (auto-refresh каждые 60 сек = τ₁) - -## Network summary - -| Метрика | Значение | -|---|---| -| Активных узлов | **3 / 3** | -| Окно сети (max) | **8** | -| Эпоха τ₂ | 0 (8/20160 окон) | -| Σ supply (closed-form) | **234 Ɉ** | - -## Узлы (Genesis-bootstrap singleton mode, M5) - -### ✅ Moscow (`local`) - -- **Phase:** Active -- **Текущее окно:** `4` -- **D (итераций SHA-256):** `325,000,000` -- **Баланс оператора:** `39.000 Ɉ` (`39,000,000,000 nɈ`) -- **Supply (closed-form):** `65.000 Ɉ` -- **AccountTable:** 1 записей -- **NodeTable:** 1 записей -- **account_id:** `4c290c3d5d63e84b99c30c83fb4d172e04102af4492b4d56d0642711b09e2072` -- **node_id:** `75bfaf9026405c12ef36437f08cc63c040cfe1924773dedcba0abadf8c6928a1` - -### ✅ Helsinki (`91.132.142.42`) - -- **Phase:** CandidateVdf -- **Текущее окно:** `8` -- **D (итераций SHA-256):** `325,000,000` -- **Баланс оператора:** `0.000 Ɉ` (`0 nɈ`) -- **Supply (closed-form):** `117.000 Ɉ` -- **AccountTable:** 1 записей -- **NodeTable:** 0 записей -- **account_id:** `19edd79c0c13b7164ed5fb00d571ba1fa26726adf1e6ef61a3f21b20fa1b42c4` -- **node_id:** `d63cc60c8367ba6be903e50bc0190d7e2e60f89f30f24d3a10dceb92613a5901` - -### ✅ Frankfurt (`89.19.208.158`) - -- **Phase:** CandidateVdf -- **Текущее окно:** `3` -- **D (итераций SHA-256):** `325,000,000` -- **Баланс оператора:** `0.000 Ɉ` (`0 nɈ`) -- **Supply (closed-form):** `52.000 Ɉ` -- **AccountTable:** 2 записей -- **NodeTable:** 1 записей -- **account_id:** `53560626aff44b5f0a88d7b235ef2028a3cf0517fd6fd2aa20b5566345a91e29` -- **node_id:** `5509211b179d69698913e47605d2b0ed24a91702fb6e9d0fbcd3c3c626270aab` - -## Архитектура снапшота - -Backend: cron на montana-moscow (Moscow node) каждую минуту собирает -`montana-node status` локально + по SSH с Helsinki + Frankfurt → JSON в -`/var/www/efir/explorer/data.json`. Frontend HTML/JS auto-refresh. - -Каждый узел в текущей версии — собственный genesis bootstrap -(M5 singleton, без сетевого слоя M6). Эмиссия 13 Ɉ за окно (≈60 сек), -τ₂ = 20160 окон (≈14 дней). 1 Ɉ = 10⁹ nɈ. diff --git a/Russian/Network/РОЛЬ_АРХИТЕКТОР_СЕТИ.md b/Russian/Network/РОЛЬ_АРХИТЕКТОР_СЕТИ.md new file mode 100644 index 00000000..8afd371e --- /dev/null +++ b/Russian/Network/РОЛЬ_АРХИТЕКТОР_СЕТИ.md @@ -0,0 +1,66 @@ +# Сеть Montana — Роль: Архитектор сети + +**Версия роли:** 1.0.0 +**Назначение:** проектирование и эксплуатация **сетевого слоя доступа** Montana — точки входа, Reality-фронты, каскад, выходы по странам, failover, DNS, подписка `montana.quest/vpn/sub`. + +Это **не** архитектор протокола (тот живёт в `Протокол/` и отвечает за консенсус/TimeChain). Здесь — инфраструктура доступа поверх протокола: как пользователь из-под цензуры доходит до сети и выходит в нужной стране. + +--- + +## Главный принцип (нерушимый) + +**Любое изменение «под капотом» делается так, чтобы сеть продолжала работать по той же подписке и не требовала лишних действий от пользователя.** + +1. **Тот же контракт подписки.** `montana.quest/vpn/sub` и хосты в выданных ссылках (`de.montana.quest` и т.д.) стабильны. Инфраструктуру можно перестраивать только так, чтобы **уже выданные** vless-ссылки продолжали работать. +2. **Ноль действий от пользователя.** Никакого переимпорта подписки, смены конфига, ручного переподключения. Переключения — на уровне DNS/маршрутизации, прозрачно для клиента. +3. **Контроль до и после.** Перед правкой снять контрольную точку, после — повторить. Контроль = `curl -s -o /dev/null -w '%{http_code}' https://montana.quest/vpn/sub` (200) + резолв точки входа + Reality-рукопожатие фронта. + +Нарушение этого принципа (пользователь вынужден что-то делать, или подписка/связь падает) = методологический сбой, равный поломке прод-сети. + +--- + +## Инварианты сети + +- **[N-1] Фиксированный контракт подписки.** Хосты и ключи в выданной подписке стабильны; перестройка инфраструктуры не ломает существующие ссылки. +- **[N-2] Прозрачное переключение.** Failover/смена фронта = переезд A-записи **фиксированного** хоста (`de.montana.quest`), а не смена хоста/ключа в подписке. Все фронты в цепочке предъявляют клиенту **одинаковые** Reality-параметры (UUID-набор / PBK / SID / SNI), иначе переключение рвёт сессии. +- **[N-3] Health-check по реальному сервису.** Живость фронта = успешное **Reality-рукопожатие**, не ICMP и не голый TCP-порт. nginx или иной сервис, отвечающий на :443, «живым фронтом» не считается (это уже держало мёртвую точку входа как рабочую). +- **[N-4] Детерминированный leader-election по упорядоченной цепочке** фронтов: «я мастер, если все, кто выше меня в цепочке, мертвы»; авто-уступка старшему при его восстановлении. (Адаптация «Тройного зеркала» с health-check по [N-3].) +- **[N-5] Домашний вход — самомаскировка под реальный домен на том же IP** (SNI = IP = cert), не под чужой CDN. Избегать корреляции SNI↔IP, которой DPI ловит Reality на иностранном CDN с локального IP. +- **[N-6] Атомарность и откат.** Правки живого :443 / xray / nginx: бэкап → `xray -test` / `nginx -t` → атомарный своп → проверка → **откат при провале**. Без отката хирургию на живом фронте не делать. +- **[N-7] Слепок сети** (`_internal-private/NETWORK-STATE-RUNBOOK.md`) обновляется и версионируется **только по явной команде автора**. +- **[N-8] Никаких IP в публичных артефактах.** Город + хостинг — да; IP — только в Cloudflare DNS и Keychain. +- **[N-9] Для мастера обязателен настроенный резерв.** Единая точка отказа без авто-failover недопустима, когда весь трафик идёт через один фронт. + +--- + +## Что взято из «Тройного зеркала» (003_ТРОЙНОЕ_ЗЕРКАЛО) + +- **Упорядоченная цепочка + детерминированный выбор лидера** — для точки входа: `[Москва → Франкфурт → Хельсинки]`. Первый живой в цепочке владеет `de.montana.quest`. Авто-failover + авто-уступка. +- **Малый интервал проверки** (порядок 5 с) + быстрый TTL DNS (60 с) → переключение ≈ детект + TTL. +- **Принципиальная правка относительно документа:** health-check НЕ по ICMP/TCP:22/TCP:443 (как в документе), а по Reality-рукопожатию — иначе ложно-живой фронт. См. [N-3]. +- Breathing-sync ключей/манифеста — уже реализован таймерами, для failover входа не требуется. + +--- + +## Топология (детали — в слепке) + +Точка входа сейчас: `de.montana.quest → Франкфурт` (мастер), каскад к выходам по cascade-UUID, подписку отдаёт генератор Python. Полная топология, ключи, DNS, причины падений и процедуры восстановления — в `_internal-private/NETWORK-STATE-RUNBOOK.md` (внутренний, с IP). + +### Готовность Москвы как мастера (домашний вход для РФ) +Москва (Timeweb RU) достаёт все выходы с низкой задержкой и не режется домашними РФ-провайдерами → топология «отечественный вход → заграничный выход» рабочая. Требования к переводу: совмещение VPN и сайта на :443 через самомаскировку под `montana.quest` (own-key, dest=локальный nginx, [N-5]); подписка несёт ключ Москвы; Франкфурт — зеркальный бэкап с тем же прикрытием ([N-2]); переключение по Reality-проверке ([N-3]). Узкое место/точка отказа смещается на Москву — резерв обязателен ([N-9]). + +--- + +## Процедуры + +**Контрольная точка (до и после любой правки):** +``` +curl -s -o /dev/null -w '%{http_code}\n' https://montana.quest/vpn/sub # ждём 200 +dig +short de.montana.quest @1.1.1.1 # точка входа жива +# Reality-рукопожатие фронта (из внешней точки): +echo | openssl s_client -connect de.montana.quest:443 -servername 2>/dev/null | grep 'Verify return' +``` + +**Атомарный своп живого :443:** бэкап конфига → собрать новый → `xray -test` / `nginx -t` → освободить/занять порт → проверить владельца :443 и контрольную точку → при любом провале вернуть бэкап и старый сервис. + +**Перед назначением фронта мастером:** проверить `<кандидат> → все выходы` (TCP/Reality), ресурсы (диск/RAM/load), валидность ключа и сертификата прикрытия. diff --git a/_internal-private/network-snapshots/NETWORK-SNAPSHOT-v2.0.0.md b/_internal-private/network-snapshots/NETWORK-SNAPSHOT-v2.0.0.md new file mode 100644 index 00000000..2043923f --- /dev/null +++ b/_internal-private/network-snapshots/NETWORK-SNAPSHOT-v2.0.0.md @@ -0,0 +1,212 @@ +# Montana — Network State Snapshot v2.0.0 + +**Дата:** 2026-05-27 +**Предыдущий:** v1.1.0 (`../NETWORK-STATE-RUNBOOK.md`) +**Статус:** ВНУТРЕННИЙ — содержит IP, ключи, токены. НЕ публиковать (правило no-IP-in-public, [[reference_metzdowd]]). +**Назначение:** полное рабочее состояние сети + точные процедуры восстановления + все известные причины падений на момент крупной переделки VPN-слоя (Москва-мастер, stream-demux, anti-block DNS, failover-watchdog, фикс маршрутизации городов). + +> Версионирование слепка — ТОЛЬКО по явной команде автора ([[feedback_network_snapshot_on_command]]). + +--- + +## 0. Что изменилось v1.1.0 → v2.0.0 (крупно) + +1. **Москва стала мастер-точкой входа** (`de.montana.quest → 176.124.208.93`), домашний РФ-вход. Раньше мастером был Франкфурт. +2. **Moscow :443 = nginx stream ssl_preread демультиплекс** (SNI googletagmanager → xray :8444 Reality; montana.quest/hub → nginx-web :8443). Совмещает VPN-вход и сайт/подписку на одном порту. +3. **Автоматический failover точки входа (3-зеркало)**: цепочка `[Москва#1 → Франкфурт#2 → Хельсинки#3]`, `montana-entry-watchdog` на всех трёх, health = Reality-рукопожатие (не TCP), идемпотентная запись `de.montana.quest`. +4. **Anti-block DNS**: в публичном DNS виден только `de`→Москва + веб; `cdn` (светил все IP) и пер-узловые имена удалены; `mess` за Cloudflare; оркестратор больше не публикует IP узлов. +5. **Фикс маршрутизации городов**: каждый город привязан к своему cascade-выходу (front-independent); Москва убрана как город-выход (только вход). +6. **Test-mode в установщике** (фикс-мнемоника / кастомный генезис / d-override) + тестовая сеть `montana-testnet-01`. + +--- + +## 1. Топология (6 узлов) + +| alias | IP | хостинг | роль (VPN) | роль (протокол montana-node) | +|-------|----|---------|-----------|------------------------------| +| moscow | 176.124.208.93 | Timeweb (RU) | **МАСТЕР-ВХОД** (фронт #1), сайт+подписка+хаб | Active (singleton genesis) | +| frankfurt | 89.19.208.158 | Timeweb (DE) | фронт #2 (резерв) + exit DE | CandidateVdf | +| helsinki | 91.132.142.42 | THE.Hosting (FI) | фронт #3 (резерв) + exit FI | CandidateVdf | +| vilnius | 149.154.185.5 | LT | exit LT (docker) | CandidateVdf (test-режим в процессе) | +| yerevan | 149.154.184.205 | WorkTitans (AM) | exit AM (docker) | CandidateVdf (test-режим в процессе) | +| nicosia | 45.9.13.170 | VMmanager (CY) | exit CY (docker) | CandidateVdf | + +SSH: `montana-moscow`/`my-timeweb` (через джамп `-J montana-frankfurt` если прямой ssh недоступен), `montana-frankfurt`, `montana-finland`, `montana-vilnius`/`montana-lithuania`, Армения `-i ~/.ssh/montana-frankfurt root@149.154.184.205`, `montana-nicosia`. + +Удалённые ранее: Amsterdam, Almaty, SPb, Novosibirsk, US/NYC (newyork запись удалена из DNS 2026-05-27). + +--- + +## 2. Архитектура входа и failover + +**Принцип:** домашний РФ-вход (Москва) → заграничный выход в выбранной стране. Клиент всегда коннектится на фиксированный `de.montana.quest`; сеть привязывает его к живому фронту и переключает при падении — пользователь ничего не делает. + +``` +клиент → de.montana.quest:443 (= текущий мастер, сейчас Москва) + → nginx stream demux: SNI googletagmanager → xray :8444 + → Reality, routing по cascade-UUID → -out → exit-страна +``` + +**Цепочка failover (приоритет):** `moscow:176.124.208.93 → frankfurt:89.19.208.158 → helsinki:91.132.142.42`. +- `montana-entry-watchdog` (systemd) на всех трёх фронтах: `/opt/montana-entry-watchdog.sh`. +- Health = Reality-рукопожатие: `openssl s_client -connect :443 -servername www.googletagmanager.com` → cert содержит `google` (Reality steal googletagmanager). НЕ голый TCP (nginx/левый сервис прошёл бы TCP-проверку — это роняло сеть). +- Логика: первый живой в цепочке владеет `de.montana.quest`; пишут идемпотентно (только при отличии cur≠target) → без гонок/флапа. Гистерезис `FAIL_THRESHOLD=3` × `CHECK_INTERVAL=15с`. TTL записи 60с. +- CF-запись `de.montana.quest` id `be42d2634fe62b7c1fd1f54058f24e27`. +- **`de.montana.quest` авто-управляемая — вручную НЕ ставить** (watchdog перепишет). +- Старый `/opt/montana-failover.sh` на Франкфурте — disabled, устарел (был дефектный TCP-health + master=Москва). + +--- + +## 3. Moscow :443 — stream-demux (вход + сайт на одном порту) + +``` +nginx stream (:443, ssl_preread) /etc/nginx/stream.d/montana-demux.conf + map $ssl_preread_server_name: + www.googletagmanager.com → 127.0.0.1:8444 (xray Reality, VPN) + default (montana.quest/hub/www) → 127.0.0.1:8443 (nginx-web: сайт, /vpn/sub, hub) +xray: listen 127.0.0.1:8444, Reality serverNames=[googletagmanager], dest=googletagmanager:443 + (сильная маскировка), универсальный ключ, 8 cascade-клиентов + outbounds +nginx-web: 127.0.0.1:8443 (montana.quest cert + hub.montana.quest cert; /vpn/sub → :5008) +``` +**xray Restart=always — при падении возможен зомби на :8444 (`bind: address already in use`).** Лечить: `systemctl stop xray; pkill -9 xray; fuser -k 8444/tcp; sleep 2; systemctl reset-failed xray; systemctl start xray`. + +--- + +## 4. Маршрутизация городов (cascade, front-independent) + +Каждый город → `de.montana.quest` (= текущий фронт) + свой cascade-UUID → фронт роутит на `-out` → выход в стране. **Москва — только вход, НЕ выход** (нет города «Москва» в подписке). + +| Город в подписке | host | cascade-UUID | outbound на фронте | Выход | +|---|---|---|---|---| +| 🇫🇮 Хельсинки Монтана | de.montana.quest | `094f9073-aff0-4c07-a4af-6ca4c924f6a9` | helsinki-out | 91.132.142.42 / FI | +| 🇩🇪 Франкфурт Монтана | de.montana.quest | `75e281f1-b702-5eb9-ba5c-8a5d38fa3c31` | frankfurt-out→direct | 89.19.208.158 / DE | +| 🇦🇲 Ереван Монтана | de.montana.quest | `43ba0c0e-c1e3-4e30-8ae8-c2e68d24d7c7` | armenia-out | 149.154.184.205 / AM | +| 🇱🇹 Вильнюс Монтана | de.montana.quest | `fc8a174d-f42b-4945-8548-ab5c9f448f81` | vilnius-out | 149.154.185.5 / LT | +| 🇨🇾 Никосия Монтана | de.montana.quest | `dad79315-0b80-5eca-9703-afee839e0131` | nicosia-out | 45.9.13.170 / CY | + +ВАЖНО (урок v2.0.0): универсальный UUID `e6d355e2…` = «прямой выход НА ФРОНТЕ». Привязывать город к нему НЕЛЬЗЯ (выйдет там, где сейчас фронт = Москва). Город всегда → свой cascade-UUID. + +--- + +## 5. Ключи Reality + +**Universal Montana key** (клиентский вход + exit-узлы helsinki/frankfurt/vilnius/yerevan/nicosia): +- UUID `e6d355e2-2d79-4c96-a373-3b0e6b6f4b0d` +- PBK `EkTs2aGKnFNgFZ0f7wgft2sJp3VjwFQqIrwkZKM4gD8` +- SID `302805bc0c25e504`, SNI `www.googletagmanager.com` +- privateKey `cL7D6FCqH5nWcQlHCKH9uNr-RNwCt5peRAqt8tl9mXs` (секрет; на каждом узле + pre-stage `/etc/montana-vpn/privkey`) + +**Cascade-UUID на фронте** (reality-entry клиенты, flow="", роутятся к exit-outbound): +- montana-universal `e6d355e2…` (→direct на фронте), yerevan `43ba0c0e…`→armenia-out, helsinki `094f9073…`→helsinki-out, vilnius `fc8a174d…`→vilnius-out, nicosia `dad79315…`→nicosia-out, frankfurt `75e281f1…`→frankfurt-out, nyc `db053bb3…`, cascade-verify `25e1779b…` + +**Moscow own-key** (PBK `svxjTnEZxk6aStkaHSYd2b-br3Pe4yqGcNrugokjEgg`, SID `f976f81b29f78c1f`, SNI montana.quest, privkey `iMWS9kMDTBsvRqXMdjXdoRg50DgB3ZRjvJEZ2LxPm3g`) — оставлен в коде sub-gen (MOSCOW_CASCADE_KEYS / OWN_KEY) но **город Москва на выход отключён**; используется только если фронт = montana.quest. + +Токены: orchestrator admin `/etc/montana/orchestrator-admin-token` (Moscow); CF API `cfut_k2tVV55op71oquxocV1OHvL9ut32WrXqFzFqQF8M1b53e6ee` (зона `2bc47161267258960d48bedfdf476f1a`), также Keychain `cloudflare-api-token`/`montana-quest`. + +--- + +## 6. DNS (Cloudflare, зона montana.quest) — anti-block минимизация + +Видно снаружи ТОЛЬКО: +``` +de.montana.quest → 176.124.208.93 (вход, авто-управляется watchdog, TTL 60, DNS-only) +montana.quest / www / hub → 176.124.208.93 (веб/подписка/хаб, Москва уже видна через de) +mess.montana.quest → CF-proxied (origin Франкфурт 89.19.208.158 скрыт; origin-rule port 8443) +messenger-api.montana.quest → CF-proxied +``` +**Удалены (2026-05-27):** `cdn.montana.quest` (multi-A, светил ВСЕ 5 IP), `entry/frankfurt/helsinki/moscow/newyork.montana.quest` (пер-узловые IP). Бэкап-фронты и выходы НЕ в постоянном DNS — появляются в `de` лишь при failover. Censor видит один домашний RU-IP. + +CF origin-rule: phase `http_request_origin`, `(http.host eq "mess.montana.quest") → origin.port=8443`. Зона SSL = strict. + +--- + +## 7. Control-plane (Moscow) + +- **orchestrator** `/opt/montana-orchestrator/server.py`, systemd `montana-orchestrator`, :5020. Registry `/var/lib/montana-orchestrator/nodes.json`. **DNS-passive** (2026-05-27): в `/register` и `watchdog_loop` отключены `cf_add` (cdn re-leak) и `apply_failover` (управлял `de`→Frankfurt, конфликтовал с entry-watchdog → флаппинг `de`). Хранит реестр для sub-gen + auto-cascade provision (см. §8). +- **sub-генератор** `/opt/montana-vpn-balance/app.py`, gunicorn :5008, `/vpn/sub`. Читает `/nodes` из оркестратора. CASCADE dict (yerevan/helsinki/vilnius/frankfurt → cascade-UUID), `if alias=="moscow": continue` (Москва не выход). Заголовки `profile-title: base64:TW9udGFuYQ==` (=Montana) + `profile-update-interval: 12`. Rust :5009 (`mt-vpn-balance`) присутствует но nginx /vpn/sub → :5008. +- **Front-provision** (на Франкфурте) `/usr/local/sbin/montana-cascade-add ` — идемпотентно добавляет cascade client+outbound+routing на фронт (бэкап→`xray -test`→один рестарт только при изменении). Оркестратор вызывает его по SSH при регистрации узла из BLOCKED_CIDRS (`149.154.0.0/16`). +- nginx: stream demux :443 (см §3) + http :80/:8443; efir_org/hub отдельными vhost. Бэкапы конфигов `/root/nginx-baks/`, `*.bak-*`. + +--- + +## 8. Авто-каскад при регистрации ([[project_montana_auto_cascade]]) + +Оркестратор `/register`: `needs_cascade = ip_in_blocked(ip) or not moscow_reachable`. BLOCKED_CIDRS=`['149.154.0.0/16']` (РКН-residential). Если да → SSH Франкфурт `montana-cascade-add` → пишет в registry `cascade_front`/`cascade_uuid`/`cascade_reason` + `moscow_reachable`/`moscow_rtt_ms`. Узлы в публичный DNS НЕ публикуются. + +--- + +## 9. Протокольный слой (montana-node, :8444 Noise_PQ XX) + +**Транспортный мэш РАБОТАЕТ** (heartbeat между узлами, Noise_PQ XX). **Консенсус НЕ единый** — каждый узел отдельная singleton-генезис-цепочка (общий генезис `network_name="montana"`, но окна расходятся, NodeTable=1 у каждого). Москва Active (sole proposer), остальные CandidateVdf (грызут admission-VDF τ₂=20160 окон ≈ 14 дней). Причина не-сходимости: нет M7 fast-sync client (отставший узел не догоняет cemented-голову) + DEV-012 multi-confirmer. Genesis-manifest: `network_name` + `peers[]` (label/multiaddr/peer_id/account_id_hex/node_id_hex/bootstrap). peer_id в манифесте косметика (dial по multiaddr). Москва :8444 ufw открыт 2026-05-27 (был закрыт → Москва была изолирована). + +**VPN-слой полностью отдельно от консенсуса** (xray :443 vs montana-node :8444; разные процессы; узел можно перезапускать не трогая VPN). + +--- + +## 10. Test-mode сети ([[установщик]]) + +Установщик (`Code/docker/runtime/entrypoint.sh` + `docker-compose.yml`, запушено `ab8910d`): +- `MONTANA_MNEMONIC` → `init --mnemonic` (фикс-identity). +- `MONTANA_GENESIS_MANIFEST_B64` → кастомный генезис (тестовая когорта) вместо запечённого. +- `MONTANA_D_TEST_OVERRIDE` → `start --d-test-override N` (малый D → окна за мс → admission за ~минуту). + +Тестовая сеть `montana-testnet-01` (2 bootstrap): +- armenia: account `c543c4151b69a7a9…`, node `1c77621274ee2f66…`, mnemonic «typical lift fork extra awesome gauge gauge senior brain social two more resource soap runway eagle alter famous cause push mystery sleep have couple» +- vilnius: account `597913015db153b0…`, node `4b125501fcf21d4e…`, mnemonic «frame transfer rug post crumble furnace barely theme square play endless december lucky season inspire rough food sister candy lunar list hollow cart icon» +- На момент слепка: Вильнюс+Армения в полной переустановке (cargo build), консенсус-тест не завершён. + +--- + +## 11. Все известные причины падений + восстановление + +### 11.1 Frankfurt xray restart-race / xray зомби на :8444 (Moscow) +`bind: address already in use`, `Start request repeated too quickly`. Лечить: `systemctl stop xray; pkill -9 xray; fuser -k /tcp; sleep 2; reset-failed; start`. + +### 11.2 `de.montana.quest` указывает на Москву, но :443 не Reality +Если на Москве :443 = nginx без stream-demux или xray с dest=google (не local) → клиенты получают не Reality → нет интернета / TLS-ошибка подписки. Должно быть: nginx stream demux (§3). Проверка: `openssl s_client -connect 127.0.0.1:443 -servername www.googletagmanager.com` → google cert; `-servername montana.quest` → montana.quest cert. + +### 11.3 Город выходит не в своей стране (напр. Франкфурт → Москва) +Причина: город привязан к универсальному UUID (`e6d355e2`, direct-на-фронте) вместо своего cascade-UUID. Лечить: в sub-gen город → его cascade-UUID (§4). Урок: смена мастер-фронта обнажает такие маппинги. + +### 11.4 `de` флаппит между узлами +Причина: два писателя DNS (entry-watchdog ↔ оркестратор apply_failover). Лечить: оркестратор DNS-passive (apply_failover/cf_add отключены); `de` пишет только entry-watchdog. + +### 11.5 cdn / пер-узловые DNS возвращаются (утечка IP флота) +Причина: оркестратор `watchdog_loop` cf_add. Лечить: cf_add отключён (§7). Удалить записи через CF API. + +### 11.6 Узел недостижим у РФ-провайдера (IP в 149.154.0.0/16) +Завернуть каскадом через достижимый фронт (авто, §8). Долгосрочно: чистый IP / T1-T3 транспорты. + +### 11.7 docker build OOM/провал +RAM<1.5G→swap; фиксы glibc/context/volume уже в install-docker.sh. + +### 11.8 hub/mess отдают чужой сертификат +Причина: vhost слушает только 127.0.0.1:8443, на :443 дефолтный блок. Лечить: добавить `listen 443 ssl` блоку (hub) ИЛИ stream-demux маршрутизирует по SNI на :8443 (Moscow). mess за CF (origin-rule :8443). + +--- + +## 12. Health-check команды + +``` +# вход жив + Reality +dig +short de.montana.quest @1.1.1.1 +echo | openssl s_client -connect de.montana.quest:443 -servername www.googletagmanager.com 2>/dev/null | grep 'Verify return' +# подписка +curl -s -o /dev/null -w '%{http_code}' https://montana.quest/vpn/sub # 200 +# города выходят правильной страной (xray-client на любом фронте, dial 176.124.208.93:443 + cascade-UUID → ifconfig.io/ip) +# watchdog +for s in montana-moscow montana-frankfurt montana-finland; do ssh $s 'systemctl is-active montana-entry-watchdog'; done +# DNS-утечки (должно быть пусто кроме de+web) +curl -s -H "Authorization: Bearer " ".../dns_records?per_page=100" +# протокол +docker exec montana-node /usr/local/bin/montana-node status --data-dir /var/lib/montana +``` + +--- + +## 13. Задеплоено vs pending + +- **Прод (живое):** Москва-мастер вход + stream-demux; failover-watchdog 3 фронта; cascade-выходы 5 стран; anti-block DNS; sub-gen :5008 (профиль Montana); auto-cascade; mess за CF. +- **В коде, НЕ задеплоено как единый консенсус:** многоузловой apply_proposal (DEV-012), M7 fast-sync client → протокол работает singleton-цепочками. +- **В процессе:** консенсус-тест на Вильнюс/Армения (montana-testnet-01, d-override). +- **Не построено:** T1-T3 транспорты, mt-egress relay (VPN как протокол-native), прикладной слой.