sync 2026-05-28T20:22:19Z

This commit is contained in:
Afgroup 2026-05-28 23:22:19 +03:00
parent 69a6432030
commit 0b779fbd5c
25 changed files with 2024 additions and 135 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@ -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). |

View File

@ -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<FastSyncChunk, WireChunkError> {
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<Vec<u8>> = 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<u8> {
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<u8>]) -> Vec<u8> {
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 })
);
}
}

View File

@ -1,3 +1,4 @@
pub mod fastsync;
pub mod init;
pub mod inspect;
pub mod start;

View File

@ -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<String>) -> u64 {
override_val
.and_then(|v| v.trim().parse::<u64>().ok())
.filter(|&t| t > 0)
.unwrap_or(FAST_SYNC_LAG_THRESHOLD)
}
pub struct StartArgs {
pub data_dir: Option<PathBuf>,
pub max_windows: Option<u64>,
@ -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<mt_genesis::GenesisManifest> = 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<u64, BTreeMap<mt_state::NodeId, BundledConfirmation>> =
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<mt_sync::FastSyncClient> = 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<u64, Hash32> = 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<BundledConfirmation> = 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<mt_state::NodeId> = 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(&current)
.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<mt_state::NodeId> = bc_accumulator
.get(&current)
.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(&current).cloned().unwrap_or_default();
let mut keys: Vec<_> = map.keys().copied().collect();
keys.sort();
keys.into_iter()
.filter_map(|k| bc_accumulator.get(&current).and_then(|m| m.get(&k)))
.collect::<Vec<_>>()
};
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(&current);
let recomputed = compute_state_root(
&state.nodes.root(),
@ -897,3 +1325,19 @@ pub struct NetworkHandle {
pub broadcast_tx: tokio::sync::mpsc::UnboundedSender<mt_net::ProtocolMessage>,
pub incoming_rx: tokio::sync::mpsc::UnboundedReceiver<mt_net::ProtocolMessage>,
}
#[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);
}
}

View File

@ -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());

View File

@ -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<Self, NodeError> {
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();
}
}

View File

@ -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,
},
],
};

View File

@ -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<String>,
/// 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<String>,
}
#[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",
..
}
));
}
}

View File

@ -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)
));
}
}

View File

@ -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<u32>,
received: BTreeSet<u32>,
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<AcceptOutcome, FastSyncClientError> {
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<u64, Hash32>,
) -> 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<u8> {
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<u8>]) -> 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<u64, Hash32> {
let mut m = BTreeMap::new();
m.insert(window, root);
m
}
fn chunk(idx: u32, total: u32, recs: Vec<Vec<u8>>) -> 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
}
));
}
}

View File

@ -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};

View File

@ -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"),

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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-256FIPS 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。蒙塔纳不是隐私混币。蒙塔纳不是收益率。蒙塔纳不是治理。蒙塔纳不是品牌。
蒙塔纳是互联网的数字原子钟。它是频率的基准,金钱、在场与历史皆由此衍生。
---
**参考实现:** RustApache-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

View File

@ -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

View File

@ -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.

View File

@ -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 через inactivity pruning.

View File

@ -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 |

View File

@ -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Ɉ.

View File

@ -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 <masq-sni> 2>/dev/null | grep 'Verify return'
```
**Атомарный своп живого :443:** бэкап конфига → собрать новый → `xray -test` / `nginx -t` → освободить/занять порт → проверить владельца :443 и контрольную точку → при любом провале вернуть бэкап и старый сервис.
**Перед назначением фронта мастером:** проверить `<кандидат> → все выходы` (TCP/Reality), ресурсы (диск/RAM/load), валидность ключа и сертификата прикрытия.

View File

@ -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 → <city>-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 <ip>: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 → фронт роутит на `<city>-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 <alias> <ip>` — идемпотентно добавляет 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 <port>/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.5Gswap; фиксы 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 <CF>" ".../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), прикладной слой.