sync 2026-05-28T20:22:19Z
This commit is contained in:
parent
69a6432030
commit
0b779fbd5c
@ -24,6 +24,8 @@
|
|||||||
|
|
||||||
## History
|
## 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 |
|
| 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). |
|
| 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). |
|
||||||
|
|||||||
@ -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 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
pub mod fastsync;
|
||||||
pub mod init;
|
pub mod init;
|
||||||
pub mod inspect;
|
pub mod inspect;
|
||||||
pub mod start;
|
pub mod start;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::Instant;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use mt_account::{apply_proposal, ProposalSettle};
|
use mt_account::{apply_proposal, ProposalSettle};
|
||||||
use mt_codec::CanonicalEncode;
|
use mt_codec::CanonicalEncode;
|
||||||
@ -13,9 +14,8 @@ use mt_entry::{
|
|||||||
};
|
};
|
||||||
use mt_genesis::genesis_params;
|
use mt_genesis::genesis_params;
|
||||||
use mt_lottery::{
|
use mt_lottery::{
|
||||||
bundle_hash, compute_endpoint, is_cemented, lottery_weight, quorum, reveal_hash,
|
bundle_hash, compute_endpoint, lottery_weight, quorum, reveal_hash, seniority_term,
|
||||||
seniority_term, validate_bundle, validate_reveal, weighted_ticket_node, BundledConfirmation,
|
validate_bundle, validate_reveal, weighted_ticket_node, BundledConfirmation, VdfReveal,
|
||||||
VdfReveal,
|
|
||||||
};
|
};
|
||||||
use mt_merkle::{empty_internal, SparseMerkleTree, TREE_DEPTH};
|
use mt_merkle::{empty_internal, SparseMerkleTree, TREE_DEPTH};
|
||||||
use mt_net::{MsgType, ProtocolMessage};
|
use mt_net::{MsgType, ProtocolMessage};
|
||||||
@ -35,6 +35,22 @@ extern "C" fn shutdown_handler(_: libc::c_int) {
|
|||||||
STOP.store(true, Ordering::SeqCst);
|
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 struct StartArgs {
|
||||||
pub data_dir: Option<PathBuf>,
|
pub data_dir: Option<PathBuf>,
|
||||||
pub max_windows: Option<u64>,
|
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)?);
|
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 current = load_current_window(&data_dir)?;
|
||||||
let mut timechain = load_or_init_timechain(&data_dir)?;
|
let mut timechain = load_or_init_timechain(&data_dir)?;
|
||||||
let mut lifecycle = load_or_init_lifecycle(&data_dir, &identity, params)?;
|
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)
|
let store = FsStore::open(&data_dir)
|
||||||
.map_err(|e| NodeError::InvalidArguments(format!("FsStore::open: {e:?}")))?;
|
.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 {
|
loop {
|
||||||
// M9 Phase 2: drain incoming Proposal envelopes от bootstrap. Decode window_index
|
// M9 Phase 2: drain incoming Proposal envelopes от bootstrap. Decode window_index
|
||||||
// и proposer_node_id напрямую из 3722-байтного header layout без полного
|
// и proposer_node_id напрямую из 3722-байтного header layout без полного
|
||||||
@ -171,13 +221,14 @@ pub fn run(args: StartArgs) -> Result<(), NodeError> {
|
|||||||
// Decode window_index + winner + proposer без полного deserialize
|
// Decode window_index + winner + proposer без полного deserialize
|
||||||
// (signature валидация в M10), apply_proposal with reconstructed
|
// (signature валидация в M10), apply_proposal with reconstructed
|
||||||
// singleton ProposalSettle. Followers stay in lockstep with Moscow.
|
// singleton ProposalSettle. Followers stay in lockstep with Moscow.
|
||||||
if msg.payload.len() != 3722 {
|
if msg.payload.len() < 3722 {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[consensus] Proposal envelope wrong size {} (expected 3722) — skip",
|
"[consensus] Proposal envelope wrong size {} (expected >= 3722) — skip",
|
||||||
msg.payload.len()
|
msg.payload.len()
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let is_cemented = msg.payload.len() > 3722;
|
||||||
let window_index = u64::from_le_bytes([
|
let window_index = u64::from_le_bytes([
|
||||||
msg.payload[32],
|
msg.payload[32],
|
||||||
msg.payload[33],
|
msg.payload[33],
|
||||||
@ -197,9 +248,194 @@ pub fn run(args: StartArgs) -> Result<(), NodeError> {
|
|||||||
eprintln!("[consensus] Proposal от не-bootstrap proposer, skip");
|
eprintln!("[consensus] Proposal от не-bootstrap proposer, skip");
|
||||||
continue;
|
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;
|
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;
|
let mut applied_count = 0u64;
|
||||||
while current < window_index {
|
while current < window_index {
|
||||||
let next_w = current + 1;
|
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 => {
|
MsgType::BundledConfirmation => {
|
||||||
// DEV-012 Phase A scaffold: count incoming BC envelopes. Full
|
// DEV-012 Phase B: validate incoming BC and insert into accumulator.
|
||||||
// multi-confirmer validate + accumulator-quorum + cemented-Proposal
|
// Quorum check + cementing is done at the top of the Active loop.
|
||||||
// broadcast is DEV-012 Phase B+C (v1.0.0 mainnet gate).
|
|
||||||
bc_count += 1;
|
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,
|
// NodeTable работает как passive follower: не producит proposal,
|
||||||
// break 'active_arm падает в post-match cleanup (candidate_expiry +
|
// break 'active_arm падает в post-match cleanup (candidate_expiry +
|
||||||
// selection_event + next_d + save_progress) — узел остаётся жив.
|
// selection_event + next_d + save_progress) — узел остаётся жив.
|
||||||
let is_singleton = state.nodes.len() == 1 && state.nodes.get(&my_node).is_some();
|
// DEV-012 multi-confirmer: bootstrap is the canonical proposer; any
|
||||||
if !is_singleton {
|
// 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!(
|
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()
|
state.nodes.len()
|
||||||
);
|
);
|
||||||
follower_skip = true;
|
follower_skip = true;
|
||||||
@ -465,12 +770,10 @@ pub fn run(args: StartArgs) -> Result<(), NodeError> {
|
|||||||
NodeError::InvalidArguments("active phase но узел не в NodeTable".into())
|
NodeError::InvalidArguments("active phase но узел не в NodeTable".into())
|
||||||
})?;
|
})?;
|
||||||
let cemented_chain_length = my_node_record.chain_length;
|
let cemented_chain_length = my_node_record.chain_length;
|
||||||
if !is_cemented(cemented_chain_length, active_chain_length) {
|
// DEV-012: cementing check is performed against the multi-confirmer
|
||||||
return Err(NodeError::InvalidArguments(format!(
|
// accumulator after broadcast+drain (below). The singleton-only check
|
||||||
"singleton cementing: cemented={cemented_chain_length}, active={active_chain_length}, quorum={}",
|
// is no longer correct in multi-Active mode.
|
||||||
quorum(active_chain_length)
|
let _ = (cemented_chain_length, active_chain_length);
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let snapshot = my_node_record.chain_length_snapshot.max(1);
|
let snapshot = my_node_record.chain_length_snapshot.max(1);
|
||||||
let _weight = lottery_weight(my_node_record.chain_length, snapshot);
|
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]),
|
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// DEV-012: insert own BC into accumulator first.
|
||||||
|
bc_accumulator
|
||||||
|
.entry(current)
|
||||||
|
.or_default()
|
||||||
|
.insert(my_node, bc.clone());
|
||||||
|
|
||||||
|
// Multi-confirmer: if not singleton, broadcast candidate (3722) first,
|
||||||
|
// then spin draining incoming for BCs from peers up to 800ms or quorum.
|
||||||
|
if state.nodes.len() > 1 {
|
||||||
|
// Build a minimal candidate header (placeholder state_root) for the
|
||||||
|
// notification broadcast. Signed scope must include real T_r so peers
|
||||||
|
// compute matching BC.endpoint.
|
||||||
|
let candidate = ProposalHeader {
|
||||||
|
prev_proposal_hash,
|
||||||
|
window_index: current,
|
||||||
|
protocol_version: 1,
|
||||||
|
control_root,
|
||||||
|
node_root: [0u8; 32],
|
||||||
|
candidate_root: state.candidates.root(),
|
||||||
|
account_root: [0u8; 32],
|
||||||
|
state_root: [0u8; 32],
|
||||||
|
timechain_value: timechain.t_r,
|
||||||
|
included_bundles_root,
|
||||||
|
included_reveals_root,
|
||||||
|
winner_endpoint: endpoint,
|
||||||
|
winner_id: my_node,
|
||||||
|
proposer_node_id: my_node,
|
||||||
|
target: u128::MAX,
|
||||||
|
fallback_depth: 1,
|
||||||
|
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
|
||||||
|
};
|
||||||
|
let mut cand_scope = Vec::new();
|
||||||
|
candidate.encode_signed_scope(&mut cand_scope);
|
||||||
|
let cand_sig =
|
||||||
|
sign(&identity.node_sk, &cand_scope).map_err(NodeError::Crypto)?;
|
||||||
|
let mut signed_cand = candidate.clone();
|
||||||
|
signed_cand.signature = cand_sig;
|
||||||
|
let mut cand_bytes = Vec::with_capacity(3722);
|
||||||
|
signed_cand.encode(&mut cand_bytes);
|
||||||
|
if let Some(ref handle) = network_handle {
|
||||||
|
let _ = handle.broadcast_tx.send(ProtocolMessage::new(
|
||||||
|
MsgType::Proposal,
|
||||||
|
current,
|
||||||
|
cand_bytes,
|
||||||
|
));
|
||||||
|
eprintln!(
|
||||||
|
"[dev-012] broadcast candidate Proposal w={current} (NodeTable.len={}, awaiting BCs)",
|
||||||
|
state.nodes.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spin draining BCs up to 800ms.
|
||||||
|
let active_sum: u64 = state.nodes.iter().map(|n| n.chain_length).sum();
|
||||||
|
let need_quorum = quorum(active_sum);
|
||||||
|
let deadline = Instant::now() + Duration::from_millis(800);
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if let Some(ref mut handle) = network_handle {
|
||||||
|
while let Ok(msg) = handle.incoming_rx.try_recv() {
|
||||||
|
if msg.msg_type == MsgType::BundledConfirmation {
|
||||||
|
if let Ok((rec_bc, _)) =
|
||||||
|
BundledConfirmation::decode(&msg.payload)
|
||||||
|
{
|
||||||
|
if rec_bc.window_index == current
|
||||||
|
&& mt_lottery::validate_bundle(
|
||||||
|
&rec_bc,
|
||||||
|
&state.nodes,
|
||||||
|
&timechain.t_r,
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
let nid = rec_bc.node_id;
|
||||||
|
bc_accumulator
|
||||||
|
.entry(current)
|
||||||
|
.or_default()
|
||||||
|
.insert(nid, rec_bc);
|
||||||
|
eprintln!(
|
||||||
|
"[dev-012] accepted BC from {} for w={current}",
|
||||||
|
hex16(&nid)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let collected: u64 = bc_accumulator
|
||||||
|
.get(¤t)
|
||||||
|
.map(|m| {
|
||||||
|
m.keys()
|
||||||
|
.filter_map(|id| state.nodes.get(id).map(|n| n.chain_length))
|
||||||
|
.sum()
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
if collected >= need_quorum {
|
||||||
|
eprintln!(
|
||||||
|
"[dev-012] quorum reached w={current}: cemented_sum={collected} >= {need_quorum}"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build final settle from accumulator. Sorted by node_id for determinism.
|
||||||
|
let confirmer_ids: Vec<mt_state::NodeId> = bc_accumulator
|
||||||
|
.get(¤t)
|
||||||
|
.map(|m| {
|
||||||
|
let mut v: Vec<_> = m.keys().copied().collect();
|
||||||
|
v.sort();
|
||||||
|
v
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| vec![my_node]);
|
||||||
let settle = ProposalSettle {
|
let settle = ProposalSettle {
|
||||||
window_w: current,
|
window_w: current,
|
||||||
winner_id: my_node,
|
winner_id: my_node,
|
||||||
cemented_confirmers: vec![my_node],
|
cemented_confirmers: confirmer_ids.clone(),
|
||||||
};
|
};
|
||||||
let post_state_root = apply_proposal(
|
let post_state_root = apply_proposal(
|
||||||
&mut state.accounts,
|
&mut state.accounts,
|
||||||
@ -557,26 +971,40 @@ pub fn run(args: StartArgs) -> Result<(), NodeError> {
|
|||||||
header.signature =
|
header.signature =
|
||||||
sign(&identity.node_sk, &header_scope).map_err(NodeError::Crypto)?;
|
sign(&identity.node_sk, &header_scope).map_err(NodeError::Crypto)?;
|
||||||
|
|
||||||
// M9 Phase 1: broadcast ProposalHeader всем connected peers через
|
// DEV-012: broadcast CEMENTED envelope: [header(3722)][u16 bundle_count][N × BC].
|
||||||
// network thread. Followers получают envelope с window_index и
|
|
||||||
// в M9 Phase 2 будут running apply_proposal locally.
|
|
||||||
if let Some(ref handle) = network_handle {
|
if let Some(ref handle) = network_handle {
|
||||||
let mut header_bytes = Vec::with_capacity(3722);
|
let mut payload = Vec::with_capacity(3722 + 2 + 4096 * confirmer_ids.len());
|
||||||
header.encode(&mut header_bytes);
|
header.encode(&mut payload);
|
||||||
|
let bundles_for_envelope: Vec<&BundledConfirmation> = {
|
||||||
|
let map = bc_accumulator.get(¤t).cloned().unwrap_or_default();
|
||||||
|
let mut keys: Vec<_> = map.keys().copied().collect();
|
||||||
|
keys.sort();
|
||||||
|
keys.into_iter()
|
||||||
|
.filter_map(|k| bc_accumulator.get(¤t).and_then(|m| m.get(&k)))
|
||||||
|
.collect::<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 =
|
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) {
|
if let Err(e) = handle.broadcast_tx.send(envelope) {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[consensus] broadcast Proposal w={} failed: {e}",
|
"[consensus] broadcast CEMENTED Proposal w={} failed: {e}",
|
||||||
header.window_index
|
header.window_index
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[consensus] broadcast Proposal window={} → peers",
|
"[consensus] broadcast CEMENTED Proposal window={} → peers (bundles={})",
|
||||||
header.window_index
|
header.window_index,
|
||||||
|
bundle_count
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Window cemented; drop its accumulator entry.
|
||||||
|
bc_accumulator.remove(¤t);
|
||||||
|
|
||||||
let recomputed = compute_state_root(
|
let recomputed = compute_state_root(
|
||||||
&state.nodes.root(),
|
&state.nodes.root(),
|
||||||
@ -897,3 +1325,19 @@ pub struct NetworkHandle {
|
|||||||
pub broadcast_tx: tokio::sync::mpsc::UnboundedSender<mt_net::ProtocolMessage>,
|
pub broadcast_tx: tokio::sync::mpsc::UnboundedSender<mt_net::ProtocolMessage>,
|
||||||
pub incoming_rx: tokio::sync::mpsc::UnboundedReceiver<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ pub fn run(args: StatusArgs) -> Result<(), NodeError> {
|
|||||||
let data_dir = args.data_dir.unwrap_or_else(default_data_dir);
|
let data_dir = args.data_dir.unwrap_or_else(default_data_dir);
|
||||||
let identity = load_identity(&data_dir)?;
|
let identity = load_identity(&data_dir)?;
|
||||||
let params = genesis_params();
|
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 current_window = load_current_window(&data_dir)?;
|
||||||
let lifecycle = load_or_init_lifecycle(&data_dir, &identity, params)?;
|
let lifecycle = load_or_init_lifecycle(&data_dir, &identity, params)?;
|
||||||
let timechain = load_or_init_timechain(&data_dir)?;
|
let timechain = load_or_init_timechain(&data_dir)?;
|
||||||
@ -54,6 +54,14 @@ pub fn run(args: StatusArgs) -> Result<(), NodeError> {
|
|||||||
println!("--- ваша identity ---");
|
println!("--- ваша identity ---");
|
||||||
println!("account_id : {}", hex_lower(&my_account));
|
println!("account_id : {}", hex_lower(&my_account));
|
||||||
println!("node_id : {}", hex_lower(&my_node));
|
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!();
|
||||||
println!("--- размеры таблиц локального state ---");
|
println!("--- размеры таблиц локального state ---");
|
||||||
println!("AccountTable : {} записей", state.accounts.len());
|
println!("AccountTable : {} записей", state.accounts.len());
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use mt_genesis::ProtocolParams;
|
use mt_crypto::PUBLIC_KEY_SIZE;
|
||||||
|
use mt_genesis::{GenesisPeer, ProtocolParams};
|
||||||
use mt_state::{
|
use mt_state::{
|
||||||
AccountRecord, AccountTable, CandidatePool, NodeTable, ACCOUNT_RECORD_SIZE,
|
AccountRecord, AccountTable, CandidatePool, NodeRecord, NodeTable, ACCOUNT_RECORD_SIZE,
|
||||||
CANDIDATE_RECORD_SIZE, NODE_RECORD_SIZE,
|
CANDIDATE_RECORD_SIZE, NODE_RECORD_SIZE,
|
||||||
};
|
};
|
||||||
use mt_store::FsStore;
|
use mt_store::FsStore;
|
||||||
@ -25,7 +26,11 @@ impl LocalState {
|
|||||||
// apply_selection_event на ближайшем W % selection_interval == 0.
|
// apply_selection_event на ближайшем W % selection_interval == 0.
|
||||||
// Operator account создаётся в обоих случаях (нужен для подписания
|
// Operator account создаётся в обоих случаях (нужен для подписания
|
||||||
// будущей NodeRegistration).
|
// будущей 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 is_genesis = NodeLifecycle::is_bootstrap_node(operator, params);
|
||||||
let mut accounts = AccountTable::new();
|
let mut accounts = AccountTable::new();
|
||||||
|
|
||||||
@ -85,6 +90,51 @@ impl LocalState {
|
|||||||
last_confirmation_window: 0,
|
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 {
|
Self {
|
||||||
accounts,
|
accounts,
|
||||||
nodes,
|
nodes,
|
||||||
@ -96,13 +146,14 @@ impl LocalState {
|
|||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
operator: &Identity,
|
operator: &Identity,
|
||||||
params: &ProtocolParams,
|
params: &ProtocolParams,
|
||||||
|
extra_actives: &[&GenesisPeer],
|
||||||
) -> Result<Self, NodeError> {
|
) -> Result<Self, NodeError> {
|
||||||
let store = FsStore::open(data_dir).map_err(|e| {
|
let store = FsStore::open(data_dir).map_err(|e| {
|
||||||
NodeError::InvalidArguments(format!("открытие хранилища {}: {e:?}", data_dir.display()))
|
NodeError::InvalidArguments(format!("открытие хранилища {}: {e:?}", data_dir.display()))
|
||||||
})?;
|
})?;
|
||||||
let accounts_path = data_dir.join("accounts.bin");
|
let accounts_path = data_dir.join("accounts.bin");
|
||||||
if !accounts_path.exists() {
|
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| {
|
let accounts = store.load_account_table().map_err(|e| {
|
||||||
NodeError::InvalidArguments(format!(
|
NodeError::InvalidArguments(format!(
|
||||||
@ -140,6 +191,29 @@ impl LocalState {
|
|||||||
.map_err(|e| NodeError::InvalidArguments(format!("save candidates: {e:?}")))?;
|
.map_err(|e| NodeError::InvalidArguments(format!("save candidates: {e:?}")))?;
|
||||||
Ok(())
|
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):
|
// SPEC DEVIATION DEV-010 (closed 2026-05-02 в M9 Phase 1):
|
||||||
@ -147,3 +221,119 @@ impl LocalState {
|
|||||||
// (а не из operator's own pk). Это унифицирует bootstrap entry между всеми
|
// (а не из operator's own pk). Это унифицирует bootstrap entry между всеми
|
||||||
// узлами cohort-а — необходимо для apply_proposal validation на receivers.
|
// узлами cohort-а — необходимо для apply_proposal validation на receivers.
|
||||||
// Inline в LocalState::bootstrap(); helper удалён.
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -106,6 +106,9 @@ async fn three_peers_establish_full_mesh_and_ping_pong() {
|
|||||||
account_id_hex: hex64(&identities[0].account_id()),
|
account_id_hex: hex64(&identities[0].account_id()),
|
||||||
node_id_hex: hex64(&identities[0].node_id()),
|
node_id_hex: hex64(&identities[0].node_id()),
|
||||||
bootstrap: true,
|
bootstrap: true,
|
||||||
|
force_active: false,
|
||||||
|
node_pubkey_hex: None,
|
||||||
|
account_pubkey_hex: None,
|
||||||
},
|
},
|
||||||
GenesisPeer {
|
GenesisPeer {
|
||||||
label: "n1".into(),
|
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()),
|
account_id_hex: hex64(&identities[1].account_id()),
|
||||||
node_id_hex: hex64(&identities[1].node_id()),
|
node_id_hex: hex64(&identities[1].node_id()),
|
||||||
bootstrap: false,
|
bootstrap: false,
|
||||||
|
force_active: false,
|
||||||
|
node_pubkey_hex: None,
|
||||||
|
account_pubkey_hex: None,
|
||||||
},
|
},
|
||||||
GenesisPeer {
|
GenesisPeer {
|
||||||
label: "n2".into(),
|
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()),
|
account_id_hex: hex64(&identities[2].account_id()),
|
||||||
node_id_hex: hex64(&identities[2].node_id()),
|
node_id_hex: hex64(&identities[2].node_id()),
|
||||||
bootstrap: false,
|
bootstrap: false,
|
||||||
|
force_active: false,
|
||||||
|
node_pubkey_hex: None,
|
||||||
|
account_pubkey_hex: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -35,6 +35,23 @@ pub struct GenesisPeer {
|
|||||||
/// Среди peers в manifest-е может быть **ровно один** bootstrap.
|
/// Среди peers в manifest-е может быть **ровно один** bootstrap.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub bootstrap: bool,
|
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)]
|
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
@ -57,6 +74,10 @@ pub enum ManifestError {
|
|||||||
expected: usize,
|
expected: usize,
|
||||||
actual: usize,
|
actual: usize,
|
||||||
},
|
},
|
||||||
|
ForceActiveMissingPubkey {
|
||||||
|
peer: String,
|
||||||
|
field: &'static str,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for ManifestError {
|
impl std::fmt::Display for ManifestError {
|
||||||
@ -78,6 +99,10 @@ impl std::fmt::Display for ManifestError {
|
|||||||
f,
|
f,
|
||||||
"поле {field}: ожидалось {expected} hex-символов, получили {actual}"
|
"поле {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(),
|
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(())
|
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> {
|
pub fn bootstrap_peer(&self) -> Option<&GenesisPeer> {
|
||||||
self.peers.iter().find(|p| p.bootstrap)
|
self.peers.iter().find(|p| p.bootstrap)
|
||||||
}
|
}
|
||||||
@ -243,4 +311,80 @@ mod tests {
|
|||||||
m.peers[0].bootstrap = false;
|
m.peers[0].bootstrap = false;
|
||||||
assert!(m.bootstrap_peer().is_none());
|
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",
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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))
|
// spec: Правило R2 — bundle_hash = SHA-256("mt-bundle" || signed_scope(bundle))
|
||||||
pub fn bundle_hash(bc: &BundledConfirmation) -> Hash32 {
|
pub fn bundle_hash(bc: &BundledConfirmation) -> Hash32 {
|
||||||
let mut scope = Vec::new();
|
let mut scope = Vec::new();
|
||||||
@ -1777,4 +1846,34 @@ mod tests {
|
|||||||
// Это эдж кейс — в реальности active=0 halt liveness, не consensus
|
// Это эдж кейс — в реальности active=0 halt liveness, не consensus
|
||||||
assert!(is_cemented(0, 0));
|
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)
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
311
Montana-Protocol/Code/crates/mt-sync/src/client.rs
Normal file
311
Montana-Protocol/Code/crates/mt-sync/src/client.rs
Normal 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
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,10 +19,12 @@
|
|||||||
//! over the existing Noise_PQ XX session — bounded by network bandwidth,
|
//! over the existing Noise_PQ XX session — bounded by network bandwidth,
|
||||||
//! not by CPU iteration count.
|
//! not by CPU iteration count.
|
||||||
|
|
||||||
|
pub mod client;
|
||||||
pub mod request;
|
pub mod request;
|
||||||
pub mod response;
|
pub mod response;
|
||||||
pub mod snapshot;
|
pub mod snapshot;
|
||||||
|
|
||||||
|
pub use client::{AcceptOutcome, FastSyncClient, FastSyncClientError};
|
||||||
pub use request::FastSyncRequest;
|
pub use request::FastSyncRequest;
|
||||||
pub use response::{FastSyncChunk, FastSyncResponse, FastSyncTableId};
|
pub use response::{FastSyncChunk, FastSyncResponse, FastSyncTableId};
|
||||||
pub use snapshot::{Snapshot, SnapshotError, SnapshotVerifier};
|
pub use snapshot::{Snapshot, SnapshotError, SnapshotVerifier};
|
||||||
|
|||||||
@ -410,14 +410,19 @@ async fn handler_balance(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handler_sub() -> impl IntoResponse {
|
async fn handler_sub() -> impl IntoResponse {
|
||||||
let link = "vless://e6d355e2-2d79-4c96-a373-3b0e6b6f4b0d@cdn.montana.quest:443\
|
let reality = "type=tcp&headerType=none&security=reality &fp=chrome&sni=www.googletagmanager.com &pbk=EkTs2aGKnFNgFZ0f7wgft2sJp3VjwFQqIrwkZKM4gD8 &sid=302805bc0c25e504";
|
||||||
?flow=xtls-rprx-vision&type=tcp&headerType=none&security=reality\
|
let entry = "entry.montana.quest:443";
|
||||||
&fp=chrome&sni=www.googletagmanager.com\
|
let links = [
|
||||||
&pbk=EkTs2aGKnFNgFZ0f7wgft2sJp3VjwFQqIrwkZKM4gD8\
|
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"),
|
||||||
&sid=302805bc0c25e504\
|
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"),
|
||||||
#%C9%88%20%D0%9C%D0%BE%D0%BD%D1%82%D0%B0%D0%BD%D0%B0";
|
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;
|
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"),
|
("content-type", "text/plain; charset=utf-8"),
|
||||||
|
|||||||
@ -33,6 +33,12 @@ services:
|
|||||||
container_name: montana-node
|
container_name: montana-node
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
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:
|
volumes:
|
||||||
- montana-data:/var/lib/montana
|
- montana-data:/var/lib/montana
|
||||||
cpus: 1.0
|
cpus: 1.0
|
||||||
|
|||||||
@ -3,8 +3,13 @@
|
|||||||
#
|
#
|
||||||
# Runs as root just long enough to make the named-volume mountpoint writable
|
# 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
|
# 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
|
# init step prints a 24-word mnemonic to stdout and saves it to mnemonic.txt.
|
||||||
# inside the volume (mode 0400, owner montana).
|
#
|
||||||
|
# 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
|
set -eu
|
||||||
|
|
||||||
@ -13,26 +18,47 @@ MNEMONIC_FILE="$DATA_DIR/mnemonic.txt"
|
|||||||
MANIFEST="/etc/montana/genesis-manifest.json"
|
MANIFEST="/etc/montana/genesis-manifest.json"
|
||||||
LISTEN="${MONTANA_LISTEN:-/ip4/0.0.0.0/tcp/8444}"
|
LISTEN="${MONTANA_LISTEN:-/ip4/0.0.0.0/tcp/8444}"
|
||||||
|
|
||||||
# Make the named volume mountpoint writable by montana.
|
|
||||||
chown -R montana:montana "$DATA_DIR"
|
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
|
if [ ! -f "$DATA_DIR/identity.bin" ]; then
|
||||||
echo "================================================================"
|
echo "================================================================"
|
||||||
echo " Montana node — first run on this volume"
|
echo " Montana node — first run on this volume"
|
||||||
echo " Generating identity. The 24 mnemonic words below are the ONLY"
|
echo " Generating identity. Save the 24 mnemonic words below."
|
||||||
echo " backup. Save them now (they will not be regenerated)."
|
|
||||||
echo "================================================================"
|
echo "================================================================"
|
||||||
|
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" \
|
runuser -u montana -- /usr/local/bin/montana-node init --data-dir "$DATA_DIR" \
|
||||||
| tee "$MNEMONIC_FILE"
|
| tee "$MNEMONIC_FILE"
|
||||||
|
fi
|
||||||
chmod 0400 "$MNEMONIC_FILE"
|
chmod 0400 "$MNEMONIC_FILE"
|
||||||
chown montana:montana "$MNEMONIC_FILE"
|
chown montana:montana "$MNEMONIC_FILE"
|
||||||
echo "================================================================"
|
echo " Mnemonic saved to $MNEMONIC_FILE (mode 0400)."
|
||||||
echo " Mnemonic also saved to $MNEMONIC_FILE (mode 0400, owner montana)."
|
|
||||||
echo " Retrieve later with: docker exec montana-node cat $MNEMONIC_FILE"
|
|
||||||
echo "================================================================"
|
|
||||||
fi
|
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" \
|
--data-dir "$DATA_DIR" \
|
||||||
--listen "$LISTEN" \
|
--listen "$LISTEN" \
|
||||||
--genesis-manifest "$MANIFEST"
|
--genesis-manifest "$MANIFEST" \
|
||||||
|
$DTEST
|
||||||
|
|||||||
81
Montana-Protocol/Manifesto/Manifesto EN.md
Normal file
81
Montana-Protocol/Manifesto/Manifesto EN.md
Normal 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
|
||||||
81
Montana-Protocol/Manifesto/Manifesto RU.md
Normal file
81
Montana-Protocol/Manifesto/Manifesto RU.md
Normal 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
|
||||||
81
Montana-Protocol/Manifesto/Manifesto ZH.md
Normal file
81
Montana-Protocol/Manifesto/Manifesto ZH.md
Normal 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-256(FIPS 180-4)顺序哈希链的工作之中。每一个时间窗口是约 `D ≈ 325 000 000` 次顺序迭代的计算,运行于普通 x86_64 处理器之上。它无法并行,无法伪造,也无法在处理器物理极限之外加速。
|
||||||
|
|
||||||
|
蒙塔纳不消耗外部时间,而是 **生产** 时间。其输出,是一支不可摧毁的密码学时间之箭——**TimeChain**。
|
||||||
|
|
||||||
|
我们刻意选用顺序 SHA-256 延迟函数,而非 Boneh-Bonneau-Bünz-Fisch [CRYPTO 2018]、Pietrzak [ITCS 2019]、Wesolowski [EUROCRYPT 2019] 的可高效验证型 VDF。验证成本等于计算成本。最小的密码学表面,本身就是审计。SHA-256 在哈希、寻址与默克尔承诺中本就必须存在,并未引入任何新的假设。
|
||||||
|
|
||||||
|
## 三、真相的层级
|
||||||
|
|
||||||
|
蒙塔纳的架构建立在严格的依赖之上,每一层都不可能脱离其下一层而成立。
|
||||||
|
|
||||||
|
1. **时间**(`TimeChain`)——不可逆的计算。物理学的基础层。每个运营者独立地嘀嗒,共同构成一个全球振荡器。
|
||||||
|
2. **在场**(`NodeChain`)——证明某个特定身份伴随了这一时间流。网络中的权重,由可证明的在场时间度量,而非由资本度量。资本不能购买时间。
|
||||||
|
3. **货币**(`Account` 与 `TimeCoin`)——可证在场的数量化衍生物。单位 `Ɉ` 不是解决无意义难题的奖励,而是网络账本中一秒钟的记录。发行量是封闭式的:`supply(W) = 13 × (W + 1) Ɉ`。没有预挖。没有预售。没有创始人份额。
|
||||||
|
4. **历史**(`Anchor`)——把任何外部事实(文档、消息、交易)绑定到这条受保护的时间线。其哈希被永久封存于 TimeChain 之中。改写它,意味着从创世起重新计算每一次 VDF 迭代。在数学上不可能。
|
||||||
|
|
||||||
|
*没有可证在场的货币,是幻影;没有可验证时间的在场,是断言;没有不可逆计算的时间,是信任。*
|
||||||
|
|
||||||
|
## 四、自第一天起的抗量子
|
||||||
|
|
||||||
|
所有共识签名采用 **ML-DSA-65**(FIPS 204)。所有传输层密钥封装采用 **ML-KEM-768**(FIPS 203)。哈希采用 **SHA-256**(FIPS 180-4)。传输握手为 **Noise_PQ XX**:双方各自的临时 ML-KEM-768、绑定到完整握手记录的 ML-DSA-65 签名、以及在已建立会话上的 ChaCha20-Poly1305 AEAD 帧加密(RFC 8439)。
|
||||||
|
|
||||||
|
没有 ECDSA。没有 EdDSA。没有经典 Diffie-Hellman。不指望 Shor 算法会迟到。
|
||||||
|
|
||||||
|
PeerId 是每个节点 ML-DSA-65 身份公钥的 SHA-256 multihash。路由身份与共识身份绑定在同一份密钥之上。
|
||||||
|
|
||||||
|
## 五、不妥协的架构
|
||||||
|
|
||||||
|
- **零手续费。** 防垃圾通过时间而非金钱实现:每身份每窗口一次操作、`account_chain_length` 阈值、资历门控。协议中任何操作都没有 `fee` 字段。
|
||||||
|
- **异步终局。** 转账不等待区块,而是由活跃运营者的 P2P 法定签名在约 300 毫秒内固化。
|
||||||
|
- **不行金权政治。** 持有十亿 `Ɉ` 的人,在共识中并不比一台 Mac Mini 的运营者拥有更多权力。发行(按时间计量)与共识(时间证明)在数学上彼此分离。抽签种子包含 `cemented_bundle_aggregate(W-2)`——攻击者若不伪造诚实参与者的签名便无法预计算的值。
|
||||||
|
- **状态中无治理。** 没有 DAO,没有金库,没有创始人否决权。咨询委员会可在协议之外存在;它们在协议之内皆无约束力。作者将自己从协议中移除。
|
||||||
|
- **不设创世节点。** 蒙塔纳以比特币式的对等网络方式启动。任何参与者皆可通过终端中的一条命令加入。不存在由创始人掌控的引导法定人数。
|
||||||
|
- **诚实 `active_chain_length` 的三分之二。** 只要诚实运营者掌握 `active_chain_length` 的三分之二以上,安全性即得到保持。资本不进入这一门槛。
|
||||||
|
|
||||||
|
## 六、规模基准
|
||||||
|
|
||||||
|
蒙塔纳的每一项决策都以 **至少十亿活跃用户** 为基准。无法扩展到 10⁹ 的机制不予讨论。账户记录为 2 059 字节;10⁹ 账户下的状态约 2.06 TB,普通磁盘即可承载。修剪规则是规范的:状态大小由活跃人口决定,而非由链龄决定。
|
||||||
|
|
||||||
|
## 七、隐私即选择
|
||||||
|
|
||||||
|
余额、转账与运营者身份默认公开。隐私通过 **Anchor** 对象实现:32 字节的哈希被提交到链上,加密内容由所有者保存在链外。协议看不见内容。隐私是用户选择保留的,而不是协议强加或禁止的。
|
||||||
|
|
||||||
|
## 八、蒙塔纳不是什么
|
||||||
|
|
||||||
|
蒙塔纳不是更快的以太坊。蒙塔纳不是 L2。蒙塔纳不是隐私混币。蒙塔纳不是收益率。蒙塔纳不是治理。蒙塔纳不是品牌。
|
||||||
|
|
||||||
|
蒙塔纳是互联网的数字原子钟。它是频率的基准,金钱、在场与历史皆由此衍生。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**参考实现:** Rust,Apache-2.0 / MIT。十二个 crate,包括 `mt-timechain`、`mt-consensus`、`mt-lottery`、`mt-crypto`、`mt-net`、`mt-noise-pq`。规范:[Whitepaper Montana.md](../Whitepaper%20Montana.md)。
|
||||||
|
|
||||||
|
**符号:** **Ɉ**——蒙塔纳时间的一秒。
|
||||||
|
|
||||||
|
Alejandro Montana
|
||||||
|
*Ничто_Nothing_无_金元Ɉ*
|
||||||
|
2026-05-28
|
||||||
20
Montana-Protocol/Manifesto/README.md
Normal file
20
Montana-Protocol/Manifesto/README.md
Normal 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
|
||||||
@ -1,6 +1,6 @@
|
|||||||
# Montana — Network Layer Specification
|
# 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).
|
**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 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.
|
- **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.
|
**[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.
|
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)
|
### 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.
|
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.
|
||||||
@ -2512,8 +2512,8 @@ Runtime коррекция учитывает фактическую длите
|
|||||||
| Hardware profile | Специфика | MH/s по локальному кварцу |
|
| Hardware profile | Специфика | MH/s по локальному кварцу |
|
||||||
|------------------|-----------|---------------------------|
|
|------------------|-----------|---------------------------|
|
||||||
| **Genesis-железо** (iMac M1 2021, idle) | Apple M1, ARM SHA-2 hw ext, 8 GB | **5.097** (нормативный) |
|
| **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 |
|
| Idle commodity VPS (x86_64, no hw SHA) | 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 |
|
| Loaded commodity VPS (x86_64, SHA-NI) | QEMU Virtual CPU v8.2.0 c SHA-NI, concurrent production сервисы на том же ядре | ~0.22 |
|
||||||
|
|
||||||
Comparative таблица иллюстрирует что hardware variance между классами достигает ×20+. Operator выбирает железо до запуска узла; недостаточная производительность означает participation_ratio < 0.85 → выпадение из active set через 8τ₂ inactivity pruning.
|
Comparative таблица иллюстрирует что hardware variance между классами достигает ×20+. Operator выбирает железо до запуска узла; недостаточная производительность означает participation_ratio < 0.85 → выпадение из active set через 8τ₂ inactivity pruning.
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
> Post-quantum reference blockchain. Sequential-delay TimeChain consensus over SHA-256. Time-as-scarcity instead of fees.
|
> 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).
|
> 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.
|
> 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).
|
> **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:
|
Montana is a post-quantum sovereignty stack. Every primitive in the protocol layer is post-quantum:
|
||||||
|
|
||||||
| Layer | Primitive | Standard |
|
| Layer | Primitive | Standard |
|
||||||
|------|-----------|----------|
|
|
||||||
| Consensus signatures | ML-DSA-65 | NIST FIPS 204 |
|
| Consensus signatures | ML-DSA-65 | NIST FIPS 204 |
|
||||||
| Application key encapsulation | ML-KEM-768 | NIST FIPS 203 |
|
| Application key encapsulation | ML-KEM-768 | NIST FIPS 203 |
|
||||||
| Transport handshake | Noise_PQ XX (ML-KEM-768 + ML-DSA-65) | This project |
|
| 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:
|
The protocol is specified as three layered documents — each independently auditable:
|
||||||
|
|
||||||
| Layer | Spec | Scope |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
## 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*.
|
- **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*.
|
- **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.
|
- **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:**
|
**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
|
## Status by milestone
|
||||||
|
|
||||||
| Milestone | State | Tests |
|
| Milestone | State | Tests |
|
||||||
|-----------|-------|-------|
|
|
||||||
| M1 foundational primitives (mt-codec, mt-crypto, mt-crypto-native, mt-mnemonic) | ready | 100+ unit + 51 NIST KAT |
|
| 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 |
|
| 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 |
|
| 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
|
## Repository layout
|
||||||
|
|
||||||
| Path | Contents |
|
| Path | Contents |
|
||||||
|------|----------|
|
|
||||||
| [`Whitepaper Montana.md`](Whitepaper%20Montana.md) | Academic paper in the style of the Bitcoin paper. Metzdowd-list submission text |
|
| [`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 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 |
|
| [`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 |
|
| [`External-Audit/`](External-Audit/) | First external security review and the project's disposition |
|
||||||
| [`Code/`](Code/) | Rust workspace — 17 crates, 9 milestones |
|
| [`Code/`](Code/) | Rust workspace — 17 crates, 9 milestones |
|
||||||
|
|||||||
@ -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Ɉ.
|
|
||||||
66
Russian/Network/РОЛЬ_АРХИТЕКТОР_СЕТИ.md
Normal file
66
Russian/Network/РОЛЬ_АРХИТЕКТОР_СЕТИ.md
Normal 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), валидность ключа и сертификата прикрытия.
|
||||||
212
_internal-private/network-snapshots/NETWORK-SNAPSHOT-v2.0.0.md
Normal file
212
_internal-private/network-snapshots/NETWORK-SNAPSHOT-v2.0.0.md
Normal 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.5G→swap; фиксы glibc/context/volume уже в install-docker.sh.
|
||||||
|
|
||||||
|
### 11.8 hub/mess отдают чужой сертификат
|
||||||
|
Причина: vhost слушает только 127.0.0.1:8443, на :443 дефолтный блок. Лечить: добавить `listen 443 ssl` блоку (hub) ИЛИ stream-demux маршрутизирует по SNI на :8443 (Moscow). mess за CF (origin-rule :8443).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Health-check команды
|
||||||
|
|
||||||
|
```
|
||||||
|
# вход жив + Reality
|
||||||
|
dig +short de.montana.quest @1.1.1.1
|
||||||
|
echo | openssl s_client -connect de.montana.quest:443 -servername www.googletagmanager.com 2>/dev/null | grep 'Verify return'
|
||||||
|
# подписка
|
||||||
|
curl -s -o /dev/null -w '%{http_code}' https://montana.quest/vpn/sub # 200
|
||||||
|
# города выходят правильной страной (xray-client на любом фронте, dial 176.124.208.93:443 + cascade-UUID → ifconfig.io/ip)
|
||||||
|
# watchdog
|
||||||
|
for s in montana-moscow montana-frankfurt montana-finland; do ssh $s 'systemctl is-active montana-entry-watchdog'; done
|
||||||
|
# DNS-утечки (должно быть пусто кроме de+web)
|
||||||
|
curl -s -H "Authorization: Bearer <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), прикладной слой.
|
||||||
Loading…
Reference in New Issue
Block a user