montana/Монтана-Протокол/Код/crates/mt-entry/src/lib.rs

1055 lines
37 KiB
Rust
Raw Normal View History

// spec, разделы "Вход и регистрация" + "NodeRegistration" + "Selection event"
// + "Adaptive VDF" + "apply_proposal steps 1/3a/3b".
//
// ============ Validate-then-apply ordering invariant ============
//
// Все public `apply_*` функции в этом крейте принимают raw types и
// предполагают что caller выполнил соответствующий `validate_*` ДО вызова.
// Это design choice (per Код/CLAUDE.md "Validate-then-apply pattern"):
// caller orchestrates validation gate explicitly, нет typestate enforcement
// через wrapper types. Скрытие в одной функции скрыло бы invariant
// ordering от каллера и усложнило бы fast-sync (validate-only) flows.
//
// Обязательный orchestration порядок per spec "apply_proposal steps":
//
// Step 1 (apply_noderegistrations_batch):
// for each NR:
// validate_noderegistration(nr, nodes, candidates, accounts)? // ДО batch apply
// apply_noderegistrations_batch(pool, ...) // applies validated
//
// Step 3a (apply_candidate_expiry):
// no validate phase — pure pruning by expires ≤ current_window
//
// Step 3b (apply_selection_event):
// no validate phase — селекция из existing CandidatePool по
// canonical sort_key; pool entries уже validated при Шаге 1
//
// Caller (mt-account::apply_proposal либо external orchestrator) ОБЯЗАН:
// 1. Provide cemented_noderegs которые passed validate_noderegistration
// 2. Maintain CandidatePool в valid state (entries только через apply_*)
// 3. Не bypass apply_* через прямую модификацию state tables
//
// Нарушение validate-then-apply ordering = protocol invariant breach,
// caller errors visible через apply_proposal Step 4 state_root mismatch
// (другие узлы recompute через canonical pipeline и detect divergence).
use mt_codec::{domain, write_bytes, write_u16, write_u64, write_u8, CanonicalEncode};
use mt_crypto::{hash, suite_id_from_u16, verify, Hash32, PublicKey, Signature, SuiteId};
use mt_genesis::ProtocolParams;
use mt_state::{
AccountId, AccountTable, CandidatePool, CandidateRecord, NodeId, NodeRecord, NodeTable,
};
// ============ Phase A: NodeRegistration ============
// spec, "NodeRegistration" под ML-DSA-65:
// type 1B <- 0x11 NodeRegistration
// suite_id 2B <- u16 LE
// node_pubkey 1952B
// operator_account_id 32B
// proof_endpoint 32B
// W_start 8B <- u64 LE
// vdf_chain_length 8B <- u64 LE
// signature 3309B <- ML-DSA-65 (was Falcon-512 666)
// Итого: 1 + 2 + 1952 + 32 + 32 + 8 + 8 + 3309 = 5344
pub const NODE_REGISTRATION_SIZE: usize = 5344;
pub const TYPE_NODE_REGISTRATION: u8 = 0x11;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct NodeRegistration {
pub suite_id: u16,
pub node_pubkey: [u8; mt_crypto::PUBLIC_KEY_SIZE],
pub operator_account_id: AccountId,
pub proof_endpoint: Hash32,
pub w_start: u64,
pub vdf_chain_length: u64,
pub signature: Signature,
}
impl NodeRegistration {
// spec R1: signed_scope = canonical_bytes без signature (last SIGNATURE_SIZE bytes)
pub fn encode_signed_scope(&self, buf: &mut Vec<u8>) {
write_u8(buf, TYPE_NODE_REGISTRATION);
write_u16(buf, self.suite_id);
write_bytes(buf, &self.node_pubkey);
write_bytes(buf, &self.operator_account_id);
write_bytes(buf, &self.proof_endpoint);
write_u64(buf, self.w_start);
write_u64(buf, self.vdf_chain_length);
}
}
impl CanonicalEncode for NodeRegistration {
fn encode(&self, buf: &mut Vec<u8>) {
self.encode_signed_scope(buf);
write_bytes(buf, self.signature.as_bytes());
}
}
// spec R2: nodereg_hash = SHA-256("mt-nodereg" || signed_scope(nr))
pub fn nodereg_hash(nr: &NodeRegistration) -> Hash32 {
let mut scope = Vec::new();
nr.encode_signed_scope(&mut scope);
hash(domain::NODEREG, &[&scope])
}
// spec: node_id = SHA-256("mt-node" || node_pubkey) — derive_node_id в mt-state
pub fn compute_node_id(node_pubkey: &[u8; mt_crypto::PUBLIC_KEY_SIZE]) -> NodeId {
mt_state::derive_node_id(node_pubkey)
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum NodeRegError {
UnsupportedSuite,
InvalidSignature,
NodeIdAlreadyInNodeTable,
NodeIdAlreadyInCandidatePool,
OperatorAccountNotFound,
OperatorAccountAlreadyNode,
WStartOutOfRange,
VdfChainTooShort,
}
// spec "Валидация NodeRegistration" строки 1783-1790.
// Проверки 1, 2, 3 (структурные) здесь. Проверки 4, 5, 6 зависят от
// canonical state на момент W_p — выполняются в apply_proposal step 1.
// Отделяем для модульности: структурные в validate_noderegistration,
// contextual в apply_noderegistration_batch (Phase E).
pub fn validate_noderegistration(
nr: &NodeRegistration,
node_table: &NodeTable,
candidate_pool: &CandidatePool,
account_table: &AccountTable,
) -> Result<(), NodeRegError> {
// (1) Suite supported
match suite_id_from_u16(nr.suite_id) {
Some(SuiteId::Mldsa65) => {},
None => return Err(NodeRegError::UnsupportedSuite),
}
// (1) Signature valid для payload.node_pubkey (SSI R1 signer_suite_id table)
let mut scope = Vec::new();
nr.encode_signed_scope(&mut scope);
let pk = PublicKey::from_array(nr.node_pubkey);
if !verify(&pk, &scope, &nr.signature) {
return Err(NodeRegError::InvalidSignature);
}
// (2) node_id уникален в Node Table и Candidate Pool
let node_id = compute_node_id(&nr.node_pubkey);
if node_table.contains(&node_id) {
return Err(NodeRegError::NodeIdAlreadyInNodeTable);
}
if candidate_pool.contains(&node_id) {
return Err(NodeRegError::NodeIdAlreadyInCandidatePool);
}
// (3) operator_account_id существует и is_node_operator = 0
let operator = account_table
.get(&nr.operator_account_id)
.ok_or(NodeRegError::OperatorAccountNotFound)?;
if operator.is_node_operator {
return Err(NodeRegError::OperatorAccountAlreadyNode);
}
Ok(())
}
// ============ Phase B: candidate_vdf_init + Candidate Pool apply ============
// spec, "Шаг 2: Кандидатура" + "[I-8] compliance" строка 1794:
// candidate_vdf_init = SHA-256(
// "mt-candidate-vdf-init" ||
// timechain_value(W_start) ||
// cemented_bundle_aggregate(W_start - 2) ||
// node_id
// )
pub fn candidate_vdf_init(
timechain_value_w_start: &Hash32,
cba_w_start_minus_2: &Hash32,
node_id: &NodeId,
) -> Hash32 {
hash(
domain::CANDIDATE_VDF_INIT,
&[timechain_value_w_start, cba_w_start_minus_2, node_id],
)
}
// spec: кандидатура истекает через `params.candidate_expiry_windows` от registration.
// [C-1] SSOT: ранее жил как hardcoded `EXPIRY_TAU2_COUNT = 3` + multiplication
// `EXPIRY_TAU2_COUNT × tau2_windows` локально; теперь читается напрямую из
// params.candidate_expiry_windows (60_480 = 3τ₂ at genesis). M4-LOW-7 closure.
pub fn compute_expiry_window(registration_window: u64, params: &ProtocolParams) -> u64 {
registration_window + params.candidate_expiry_windows
}
// spec "apply_proposal шаг 3a": удалить кандидатов с expires ≤ current_window.
// Возвращает Vec<NodeId> удалённых (для архивации / метрик).
pub fn apply_candidate_expiry(pool: &mut CandidatePool, current_window: u64) -> Vec<NodeId> {
let to_remove: Vec<NodeId> = pool
.iter()
.filter(|c| c.expires <= current_window)
.map(|c| c.node_id)
.collect();
for id in &to_remove {
pool.remove(id);
}
to_remove
}
// ============ Phase C: Selection event ============
// spec, "Selection event":
// slots = max(1, floor(active_nodes / params.admission_divisor)) — 1% upper bound per event
// [C-1] SSOT: ранее жил как hardcoded `ADMISSION_DIVISOR = 130`; теперь
// читается из params.admission_divisor (130 at genesis). M4-LOW-7 closure.
pub fn selection_slots(active_nodes: u64, params: &ProtocolParams) -> u64 {
(active_nodes / params.admission_divisor).max(1)
}
// spec "Selection event sort_key":
// sort_key(c) = SHA-256("mt-selection" || timechain_value(W) ||
// cemented_bundle_aggregate(W-2) || c.node_id)
pub fn selection_sort_key(
timechain_value_w: &Hash32,
cba_w_minus_2: &Hash32,
node_id: &NodeId,
) -> Hash32 {
hash(
domain::SELECTION,
&[timechain_value_w, cba_w_minus_2, node_id],
)
}
// spec "apply_proposal шаг 3b":
// 1. Compute sort_key для каждого candidate
// 2. Sort ascending by sort_key
// 3. Take first `slots` кандидатов → add to Node Table, mark operator is_node_operator=1
// 4. Remove selected from CandidatePool
// Trigger: каждые params.selection_interval окон (336 at genesis).
// [C-1] SSOT: ранее жил как hardcoded `SELECTION_INTERVAL = 336`; теперь
// читается из params.selection_interval. M4-LOW-7 closure.
pub fn is_selection_window(window: u64, params: &ProtocolParams) -> bool {
window != 0 && window % params.selection_interval == 0
}
// Возвращает отсортированный список (sort_key, candidate) — caller применяет.
pub fn rank_candidates_for_selection(
pool: &CandidatePool,
timechain_value_w: &Hash32,
cba_w_minus_2: &Hash32,
) -> Vec<(Hash32, CandidateRecord)> {
let mut scored: Vec<(Hash32, CandidateRecord)> = pool
.iter()
.map(|c| {
let key = selection_sort_key(timechain_value_w, cba_w_minus_2, &c.node_id);
(key, c.clone())
})
.collect();
// Canonical sort: sort_key ascending (32B lex)
scored.sort_by_key(|s| s.0);
scored
}
// spec "apply_proposal шаг 3b" + "Шаг 4: Регистрация" (строки 1798-1806):
// На selection event:
// 1. Rank candidates by sort_key asc
// 2. Take первые `slots` (selection_slots(active_nodes))
// 3. Для каждого выбранного:
// - Добавить в Node Table с start_window = W, chain_length = 1
// - Пометить operator_account_id как is_node_operator = 1
// - Удалить из Candidate Pool
// Возвращает список активированных node_ids.
#[allow(clippy::too_many_arguments)]
pub fn apply_selection_event(
pool: &mut CandidatePool,
node_table: &mut NodeTable,
account_table: &mut AccountTable,
timechain_value_w: &Hash32,
cba_w_minus_2: &Hash32,
active_nodes: u64,
w: u64,
params: &ProtocolParams,
) -> Vec<NodeId> {
let slots = selection_slots(active_nodes, params) as usize;
let ranked = rank_candidates_for_selection(pool, timechain_value_w, cba_w_minus_2);
let selected: Vec<CandidateRecord> = ranked.into_iter().take(slots).map(|(_, c)| c).collect();
let mut activated = Vec::new();
for cand in selected {
// Step 4: добавить в Node Table с chain_length = 1 (spec строка 1802)
let node_record = NodeRecord {
node_id: cand.node_id,
node_pubkey: cand.node_pubkey,
suite_id: cand.suite_id,
operator_account_id: cand.operator_account_id,
start_window: w,
chain_length: 1,
chain_length_snapshot: 0,
chain_length_checkpoints: [0; 6],
last_confirmation_window: 0,
};
node_table.insert(node_record);
// Пометить operator is_node_operator = 1 (spec строка 1806)
if let Some(acc) = account_table.get(&cand.operator_account_id) {
let mut updated = acc.clone();
updated.is_node_operator = true;
account_table.insert(updated);
}
// Remove from Candidate Pool
pool.remove(&cand.node_id);
activated.push(cand.node_id);
}
activated
}
// ============ Phase D: Adaptive VDF length ============
// spec, "Adaptive VDF" строки 1816-1831:
// candidate_pressure(W) = pending_candidates(W) / active_nodes(W)
//
// if candidate_pressure(W) > 0.01:
// required_vdf_length(W) = τ₂_windows × candidate_pressure(W) × 100
// else:
// required_vdf_length(W) = τ₂_windows (base)
//
// Integer form per [I-9]:
// pressure_permille = (pending * 1000) / active [u64, 0..=1000+]
// Если pressure_permille > 10 (= 1%):
// required = τ₂_windows × pressure_permille × 100 / 1000
// = τ₂_windows × pressure_permille / 10
// Иначе:
// required = τ₂_windows
pub fn required_vdf_length(pending_candidates: u64, active_nodes: u64, tau2_windows: u64) -> u64 {
if active_nodes == 0 {
// Genesis / degenerate — нет активных узлов
return tau2_windows;
}
// pressure_permille = (pending * 1000) / active
// Overflow: pending ≤ 10^6, × 1000 ≤ 10^9, safe u64
let pressure_permille = (pending_candidates.saturating_mul(1000)) / active_nodes;
if pressure_permille > 10 {
// required = τ₂ × pressure_permille / 10
// Overflow: τ₂ ≤ 78000, × pressure_permille ≤ 78000 × 10^6 ≈ 10^11, safe u64
tau2_windows.saturating_mul(pressure_permille) / 10
} else {
tau2_windows
}
}
// ============ Phase E: Incremental apply in batch (apply_proposal step 1) ============
// spec "Incremental apply в батче" строки 1835-1854 + "nr_sort_key" 1837-1843:
// nr_sort_key(nr) = SHA-256(
// "mt-nodereg-sort" ||
// timechain_value(W_p) ||
// cemented_bundle_aggregate(W_p - 2) ||
// nr.node_pubkey
// )
pub fn nr_sort_key(
timechain_value_w_p: &Hash32,
cba_w_p_minus_2: &Hash32,
node_pubkey: &[u8; mt_crypto::PUBLIC_KEY_SIZE],
) -> Hash32 {
hash(
domain::NODEREG_SORT,
&[timechain_value_w_p, cba_w_p_minus_2, node_pubkey],
)
}
// spec "apply_proposal шаг 1" + "Incremental apply":
// 1. Sort cemented_noderegs by nr_sort_key asc
// 2. Для каждой NR в порядке:
// current_pending = pending_baseline + N_already_applied
// current_pressure = current_pending / active_nodes
// required = adaptive_formula(current_pressure)
// if NR.vdf_chain_length >= required: apply; N += 1
// else: reject
// 3. Apply = insert в CandidatePool с registration_window = W_p,
// expires = W_p + 3τ₂.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BatchOutcome {
pub applied: Vec<NodeId>,
pub rejected: Vec<NodeId>,
}
#[allow(clippy::too_many_arguments)]
pub fn apply_noderegistrations_batch(
pool: &mut CandidatePool,
cemented_noderegs: &[NodeRegistration],
timechain_value_w_p: &Hash32,
cba_w_p_minus_2: &Hash32,
pending_baseline: u64,
active_nodes: u64,
w_p: u64,
params: &ProtocolParams,
) -> BatchOutcome {
// Canonical sort by nr_sort_key
let mut sorted: Vec<&NodeRegistration> = cemented_noderegs.iter().collect();
sorted.sort_by_key(|nr| nr_sort_key(timechain_value_w_p, cba_w_p_minus_2, &nr.node_pubkey));
let mut applied_count: u64 = 0;
let mut applied = Vec::new();
let mut rejected = Vec::new();
for nr in sorted {
let current_pending = pending_baseline + applied_count;
let required = required_vdf_length(current_pending, active_nodes, params.tau2_windows);
let node_id = compute_node_id(&nr.node_pubkey);
if nr.vdf_chain_length >= required {
let rec = CandidateRecord {
node_id,
node_pubkey: nr.node_pubkey,
suite_id: nr.suite_id,
operator_account_id: nr.operator_account_id,
proof_endpoint: nr.proof_endpoint,
w_start: nr.w_start,
vdf_chain_length: nr.vdf_chain_length,
registration_window: w_p,
expires: compute_expiry_window(w_p, params),
};
pool.insert(rec);
applied.push(node_id);
applied_count += 1;
} else {
rejected.push(node_id);
}
}
BatchOutcome { applied, rejected }
}
#[cfg(test)]
mod tests {
use super::*;
use mt_crypto::{keypair, sign, PublicKey, SECRET_KEY_SIZE, SIGNATURE_SIZE};
use mt_state::{AccountRecord, CandidateRecord};
const TAU2: u64 = 20_160; // per spec
fn make_account(id_byte: u8, is_op: bool) -> AccountRecord {
AccountRecord {
account_id: [id_byte; 32],
balance: 1000,
suite_id: SuiteId::Mldsa65 as u16,
is_node_operator: is_op,
frontier_hash: [0; 32],
op_height: 0,
account_chain_length: 5,
account_chain_length_snapshot: 5,
current_pubkey: [0; mt_crypto::PUBLIC_KEY_SIZE],
creation_window: 1,
last_op_window: 2,
last_activation_window: 0,
}
}
fn stub_nr(pubkey: [u8; mt_crypto::PUBLIC_KEY_SIZE], vdf_len: u64) -> NodeRegistration {
NodeRegistration {
suite_id: SuiteId::Mldsa65 as u16,
node_pubkey: pubkey,
operator_account_id: [0x11; 32],
proof_endpoint: [0x33; 32],
w_start: 100,
vdf_chain_length: vdf_len,
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
}
}
fn sign_nr(nr: &mut NodeRegistration, sk: &mt_crypto::SecretKey) {
let mut scope = Vec::new();
nr.encode_signed_scope(&mut scope);
nr.signature = sign(sk, &scope).expect("sign NodeRegistration scope");
}
// Phase A tests
#[test]
fn nodereg_size_constant() {
assert_eq!(NODE_REGISTRATION_SIZE, 5344);
}
#[test]
fn nodereg_type_byte_0x11() {
assert_eq!(TYPE_NODE_REGISTRATION, 0x11);
}
#[test]
fn encode_matches_spec_layout() {
let (pk, _sk) = keypair();
let nr = stub_nr(*pk.as_bytes(), 100);
let mut buf = Vec::new();
nr.encode(&mut buf);
assert_eq!(buf.len(), NODE_REGISTRATION_SIZE);
assert_eq!(buf[0], TYPE_NODE_REGISTRATION);
assert_eq!(&buf[1..3], &(SuiteId::Mldsa65 as u16).to_le_bytes());
// node_pubkey: [3..3+1952] = 1952 байт ML-DSA-65 pubkey
let pk_end = 3 + mt_crypto::PUBLIC_KEY_SIZE;
assert_eq!(&buf[3..pk_end], pk.as_bytes());
// operator_account_id 32B
assert_eq!(&buf[pk_end..pk_end + 32], &[0x11; 32]);
// proof_endpoint 32B
assert_eq!(&buf[pk_end + 32..pk_end + 64], &[0x33; 32]);
// w_start u64 LE
assert_eq!(&buf[pk_end + 64..pk_end + 72], &100u64.to_le_bytes());
// vdf_chain_length u64 LE
assert_eq!(&buf[pk_end + 72..pk_end + 80], &100u64.to_le_bytes());
}
#[test]
fn signed_scope_excludes_signature() {
let (pk, _sk) = keypair();
let nr = stub_nr(*pk.as_bytes(), 100);
let mut scope = Vec::new();
nr.encode_signed_scope(&mut scope);
assert_eq!(scope.len(), NODE_REGISTRATION_SIZE - SIGNATURE_SIZE);
}
#[test]
fn nodereg_hash_domain() {
let (pk, _sk) = keypair();
let nr = stub_nr(*pk.as_bytes(), 100);
let mut scope = Vec::new();
nr.encode_signed_scope(&mut scope);
assert_eq!(nodereg_hash(&nr), hash(b"mt-nodereg", &[&scope]));
}
#[test]
fn validate_accepts_valid() {
let (pk, sk) = keypair();
let mut nr = stub_nr(*pk.as_bytes(), 100);
let operator = make_account(0x11, false);
let mut at = AccountTable::new();
at.insert(operator);
sign_nr(&mut nr, &sk);
let nt = NodeTable::new();
let pool = CandidatePool::new();
assert_eq!(validate_noderegistration(&nr, &nt, &pool, &at), Ok(()));
}
#[test]
fn validate_rejects_unsupported_suite() {
let (pk, _sk) = keypair();
let mut nr = stub_nr(*pk.as_bytes(), 100);
nr.suite_id = 0xFFFF;
let nt = NodeTable::new();
let pool = CandidatePool::new();
let at = AccountTable::new();
assert_eq!(
validate_noderegistration(&nr, &nt, &pool, &at),
Err(NodeRegError::UnsupportedSuite)
);
}
#[test]
fn validate_rejects_bad_signature() {
let (pk, sk) = keypair();
let mut nr = stub_nr(*pk.as_bytes(), 100);
sign_nr(&mut nr, &sk);
let mut sig_bytes = *nr.signature.as_bytes();
sig_bytes[0] ^= 0xFF;
nr.signature = Signature::from_array(sig_bytes);
let operator = make_account(0x11, false);
let mut at = AccountTable::new();
at.insert(operator);
let nt = NodeTable::new();
let pool = CandidatePool::new();
assert_eq!(
validate_noderegistration(&nr, &nt, &pool, &at),
Err(NodeRegError::InvalidSignature)
);
}
#[test]
fn validate_rejects_operator_not_found() {
let (pk, sk) = keypair();
let mut nr = stub_nr(*pk.as_bytes(), 100);
sign_nr(&mut nr, &sk);
let nt = NodeTable::new();
let pool = CandidatePool::new();
let at = AccountTable::new(); // no account
assert_eq!(
validate_noderegistration(&nr, &nt, &pool, &at),
Err(NodeRegError::OperatorAccountNotFound)
);
}
#[test]
fn validate_rejects_operator_already_node() {
let (pk, sk) = keypair();
let mut nr = stub_nr(*pk.as_bytes(), 100);
sign_nr(&mut nr, &sk);
let operator = make_account(0x11, true); // already node operator
let mut at = AccountTable::new();
at.insert(operator);
let nt = NodeTable::new();
let pool = CandidatePool::new();
assert_eq!(
validate_noderegistration(&nr, &nt, &pool, &at),
Err(NodeRegError::OperatorAccountAlreadyNode)
);
}
#[test]
fn compute_node_id_matches_mt_state() {
let pk = [0x42u8; mt_crypto::PUBLIC_KEY_SIZE];
assert_eq!(compute_node_id(&pk), mt_state::derive_node_id(&pk));
}
// Phase B tests
#[test]
fn candidate_vdf_init_formula() {
let t_r: Hash32 = [0x10; 32];
let cba: Hash32 = [0x20; 32];
let node_id: NodeId = [0x30; 32];
let got = candidate_vdf_init(&t_r, &cba, &node_id);
let expected = hash(b"mt-candidate-vdf-init", &[&t_r, &cba, &node_id]);
assert_eq!(got, expected);
}
#[test]
fn candidate_vdf_init_sensitivity() {
let base = candidate_vdf_init(&[1; 32], &[2; 32], &[3; 32]);
assert_ne!(candidate_vdf_init(&[9; 32], &[2; 32], &[3; 32]), base);
assert_ne!(candidate_vdf_init(&[1; 32], &[9; 32], &[3; 32]), base);
assert_ne!(candidate_vdf_init(&[1; 32], &[2; 32], &[9; 32]), base);
}
#[test]
fn expiry_window_is_3_tau2_later() {
let p = mt_genesis::genesis_params();
// params.candidate_expiry_windows = 3 × tau2 at genesis
assert_eq!(
compute_expiry_window(100, p),
100 + p.candidate_expiry_windows
);
assert_eq!(p.candidate_expiry_windows, 3 * p.tau2_windows);
}
#[test]
fn apply_candidate_expiry_removes_expired() {
let mut pool = CandidatePool::new();
for i in 0..5u8 {
pool.insert(CandidateRecord {
node_id: [i; 32],
node_pubkey: [0; mt_crypto::PUBLIC_KEY_SIZE],
suite_id: 1,
operator_account_id: [0; 32],
proof_endpoint: [0; 32],
w_start: 0,
vdf_chain_length: 0,
registration_window: 0,
expires: 10 + i as u64,
});
}
let removed = apply_candidate_expiry(&mut pool, 12);
// Expires 10, 11, 12 removed; 13, 14 remain
assert_eq!(removed.len(), 3);
assert_eq!(pool.len(), 2);
}
// Phase C tests
#[test]
fn selection_slots_at_least_one() {
let p = mt_genesis::genesis_params();
assert_eq!(selection_slots(0, p), 1);
assert_eq!(selection_slots(100, p), 1);
assert_eq!(selection_slots(129, p), 1);
}
#[test]
fn selection_slots_one_percent_cap() {
let p = mt_genesis::genesis_params();
assert_eq!(selection_slots(130, p), 1);
assert_eq!(selection_slots(260, p), 2);
assert_eq!(selection_slots(1300, p), 10);
assert_eq!(selection_slots(13000, p), 100);
}
#[test]
fn selection_sort_key_formula() {
let t_r: Hash32 = [0x10; 32];
let cba: Hash32 = [0x20; 32];
let node_id: NodeId = [0x30; 32];
let got = selection_sort_key(&t_r, &cba, &node_id);
assert_eq!(got, hash(b"mt-selection", &[&t_r, &cba, &node_id]));
}
#[test]
fn is_selection_window_at_intervals() {
let p = mt_genesis::genesis_params();
assert!(!is_selection_window(0, p)); // Genesis exclusion
assert!(!is_selection_window(1, p));
assert!(!is_selection_window(335, p));
assert!(is_selection_window(336, p));
assert!(!is_selection_window(337, p));
assert!(is_selection_window(672, p));
}
#[test]
fn rank_candidates_sort_deterministic() {
let mut pool = CandidatePool::new();
for i in 0..3u8 {
pool.insert(CandidateRecord {
node_id: [i; 32],
node_pubkey: [0; mt_crypto::PUBLIC_KEY_SIZE],
suite_id: 1,
operator_account_id: [0; 32],
proof_endpoint: [0; 32],
w_start: 0,
vdf_chain_length: 0,
registration_window: 0,
expires: 1000,
});
}
let ranked = rank_candidates_for_selection(&pool, &[0x11; 32], &[0x22; 32]);
assert_eq!(ranked.len(), 3);
// Sort key is deterministic — check that two calls give same order
let ranked2 = rank_candidates_for_selection(&pool, &[0x11; 32], &[0x22; 32]);
for i in 0..3 {
assert_eq!(ranked[i].0, ranked2[i].0);
assert_eq!(ranked[i].1.node_id, ranked2[i].1.node_id);
}
}
// Phase D tests
#[test]
fn required_vdf_base_low_pressure() {
// pressure = 5/1000 = 0.5% < 1% threshold → base τ₂
assert_eq!(required_vdf_length(5, 1000, TAU2), TAU2);
}
#[test]
fn required_vdf_exactly_1_percent_is_base() {
// pressure_permille = 10 (1%) → не > 10, base
assert_eq!(required_vdf_length(10, 1000, TAU2), TAU2);
}
#[test]
fn required_vdf_moderate_pressure() {
// pressure = 20/1000 = 2% = 20 permille → required = τ₂ × 20 / 10 = 2τ₂
assert_eq!(required_vdf_length(20, 1000, TAU2), 2 * TAU2);
}
#[test]
fn required_vdf_high_pressure() {
// pressure = 100/1000 = 10% = 100 permille → 10 × τ₂
assert_eq!(required_vdf_length(100, 1000, TAU2), 10 * TAU2);
}
#[test]
fn required_vdf_attack_pressure() {
// pressure = 1000/1000 = 100% = 1000 permille → 100 × τ₂
assert_eq!(required_vdf_length(1000, 1000, TAU2), 100 * TAU2);
}
#[test]
fn required_vdf_active_zero_returns_base() {
// Защита от division by zero
assert_eq!(required_vdf_length(10, 0, TAU2), TAU2);
}
// Phase E tests
#[test]
fn nr_sort_key_formula() {
let t_r: Hash32 = [0x10; 32];
let cba: Hash32 = [0x20; 32];
let pk = [0x30u8; mt_crypto::PUBLIC_KEY_SIZE];
let got = nr_sort_key(&t_r, &cba, &pk);
assert_eq!(got, hash(b"mt-nodereg-sort", &[&t_r, &cba, &pk]));
}
#[test]
fn batch_single_nr_applies() {
let (pk, sk) = keypair();
let mut nr = stub_nr(*pk.as_bytes(), TAU2);
sign_nr(&mut nr, &sk);
let mut pool = CandidatePool::new();
let outcome = apply_noderegistrations_batch(
&mut pool,
&[nr],
&[0; 32],
&[0; 32],
0,
1000,
100,
mt_genesis::genesis_params(),
);
assert_eq!(outcome.applied.len(), 1);
assert_eq!(outcome.rejected.len(), 0);
assert_eq!(pool.len(), 1);
}
#[test]
fn batch_insufficient_vdf_rejected() {
let (pk, sk) = keypair();
let mut nr = stub_nr(*pk.as_bytes(), TAU2 - 1); // shortfall
sign_nr(&mut nr, &sk);
let mut pool = CandidatePool::new();
let outcome = apply_noderegistrations_batch(
&mut pool,
&[nr],
&[0; 32],
&[0; 32],
0,
1000,
100,
mt_genesis::genesis_params(),
);
assert_eq!(outcome.applied.len(), 0);
assert_eq!(outcome.rejected.len(), 1);
assert_eq!(pool.len(), 0);
}
#[test]
fn batch_incremental_pending_increases() {
// Publish 3 NR, each requires base initially — но после первой pending += 1
let mut nrs = Vec::new();
for _ in 0..3 {
let (pk, sk) = keypair();
let mut nr = stub_nr(*pk.as_bytes(), TAU2);
// Change operator_account to unique id per NR
nr.operator_account_id = [nrs.len() as u8 + 1; 32];
sign_nr(&mut nr, &sk);
nrs.push(nr);
}
// Starting pending = 0, active = 100 → pressure starts low, after each applied grows
// pending = 0, 1, 2 → permille 0, 10, 20
// NR1: pressure=0 → base; NR2: pressure=10 → base; NR3: pressure=20 → 2×base
// Third NR has TAU2 = base, not 2×base → rejected
let mut pool = CandidatePool::new();
let outcome = apply_noderegistrations_batch(
&mut pool,
&nrs,
&[0; 32],
&[0; 32],
0,
100,
100,
mt_genesis::genesis_params(),
);
// First 2 applied, third rejected
assert_eq!(outcome.applied.len(), 2);
assert_eq!(outcome.rejected.len(), 1);
}
#[test]
fn batch_sort_by_nr_sort_key() {
// Two NRs — проверяем что sort применяется (deterministic order)
let (pk1, sk1) = keypair();
let (pk2, sk2) = keypair();
let mut nr1 = stub_nr(*pk1.as_bytes(), 10 * TAU2);
nr1.operator_account_id = [0x01; 32];
let mut nr2 = stub_nr(*pk2.as_bytes(), 10 * TAU2);
nr2.operator_account_id = [0x02; 32];
sign_nr(&mut nr1, &sk1);
sign_nr(&mut nr2, &sk2);
let p = mt_genesis::genesis_params();
let mut pool1 = CandidatePool::new();
let o1 = apply_noderegistrations_batch(
&mut pool1,
&[nr1.clone(), nr2.clone()],
&[0; 32],
&[0; 32],
0,
1000,
100,
p,
);
let mut pool2 = CandidatePool::new();
let o2 = apply_noderegistrations_batch(
&mut pool2,
&[nr2, nr1],
&[0; 32],
&[0; 32],
0,
1000,
100,
p,
);
// Order applied должен быть same (sort deterministic)
assert_eq!(o1.applied, o2.applied);
}
#[test]
fn secret_key_size_sanity() {
// ML-DSA-65 expanded secret key
assert_eq!(SECRET_KEY_SIZE, 4032);
}
#[test]
fn batch_registration_window_and_expiry_set() {
let (pk, sk) = keypair();
let mut nr = stub_nr(*pk.as_bytes(), TAU2);
sign_nr(&mut nr, &sk);
let mut pool = CandidatePool::new();
apply_noderegistrations_batch(
&mut pool,
&[nr],
&[0; 32],
&[0; 32],
0,
1000,
100,
mt_genesis::genesis_params(),
);
let (_, rec) = pool.iter().next().map(|c| (c.node_id, c.clone())).unwrap();
assert_eq!(rec.registration_window, 100);
assert_eq!(rec.expires, 100 + 3 * TAU2);
}
#[test]
fn public_key_used_in_sort_key_not_node_id() {
// nr_sort_key использует node_pubkey напрямую (не node_id)
let pk = [0x42u8; mt_crypto::PUBLIC_KEY_SIZE];
let t_r: Hash32 = [0; 32];
let cba: Hash32 = [0; 32];
let key1 = nr_sort_key(&t_r, &cba, &pk);
// Разные pubkey → разные keys
let pk2 = [0x43u8; mt_crypto::PUBLIC_KEY_SIZE];
let key2 = nr_sort_key(&t_r, &cba, &pk2);
assert_ne!(key1, key2);
}
#[test]
fn pk_import_sanity() {
// PublicKey import used
let _ = PublicKey::from_array([0; mt_crypto::PUBLIC_KEY_SIZE]);
}
#[test]
fn apply_selection_event_activates_top_k_candidates() {
let mut pool = CandidatePool::new();
let mut nt = NodeTable::new();
let mut at = AccountTable::new();
// Create 3 candidates + 3 operator accounts
for i in 1u8..=3 {
at.insert(make_account(i, false));
pool.insert(CandidateRecord {
node_id: [i; 32],
node_pubkey: [i; mt_crypto::PUBLIC_KEY_SIZE],
suite_id: 1,
operator_account_id: [i; 32],
proof_endpoint: [0; 32],
w_start: 0,
vdf_chain_length: 0,
registration_window: 0,
expires: 10_000,
});
}
// active_nodes=130 → slots = 1 (min)
let activated = apply_selection_event(
&mut pool,
&mut nt,
&mut at,
&[0x11; 32],
&[0x22; 32],
130,
336,
mt_genesis::genesis_params(),
);
assert_eq!(activated.len(), 1);
assert_eq!(nt.len(), 1);
assert_eq!(pool.len(), 2); // 2 остались в pool
// Активированный operator помечен
let op = at.get(&activated[0]).unwrap();
assert!(op.is_node_operator);
}
#[test]
fn apply_selection_event_multiple_slots() {
let mut pool = CandidatePool::new();
let mut nt = NodeTable::new();
let mut at = AccountTable::new();
for i in 1u8..=5 {
at.insert(make_account(i, false));
pool.insert(CandidateRecord {
node_id: [i; 32],
node_pubkey: [i; mt_crypto::PUBLIC_KEY_SIZE],
suite_id: 1,
operator_account_id: [i; 32],
proof_endpoint: [0; 32],
w_start: 0,
vdf_chain_length: 0,
registration_window: 0,
expires: 10_000,
});
}
// active_nodes=260 → slots = 2
let activated = apply_selection_event(
&mut pool,
&mut nt,
&mut at,
&[0x11; 32],
&[0x22; 32],
260,
336,
mt_genesis::genesis_params(),
);
assert_eq!(activated.len(), 2);
assert_eq!(nt.len(), 2);
assert_eq!(pool.len(), 3);
}
#[test]
fn apply_selection_event_empty_pool() {
let mut pool = CandidatePool::new();
let mut nt = NodeTable::new();
let mut at = AccountTable::new();
let activated = apply_selection_event(
&mut pool,
&mut nt,
&mut at,
&[0; 32],
&[0; 32],
130,
336,
mt_genesis::genesis_params(),
);
assert!(activated.is_empty());
assert!(nt.is_empty());
}
#[test]
fn apply_selection_event_new_node_chain_length_1() {
// spec строка 1802: chain_length = 1 при активации
let mut pool = CandidatePool::new();
let mut nt = NodeTable::new();
let mut at = AccountTable::new();
at.insert(make_account(1, false));
pool.insert(CandidateRecord {
node_id: [1; 32],
node_pubkey: [1; mt_crypto::PUBLIC_KEY_SIZE],
suite_id: 1,
operator_account_id: [1; 32],
proof_endpoint: [0; 32],
w_start: 0,
vdf_chain_length: 0,
registration_window: 0,
expires: 10_000,
});
let activated = apply_selection_event(
&mut pool,
&mut nt,
&mut at,
&[0x11; 32],
&[0x22; 32],
130,
500,
mt_genesis::genesis_params(),
);
let new_node = nt.get(&activated[0]).unwrap();
assert_eq!(new_node.chain_length, 1);
assert_eq!(new_node.start_window, 500);
}
}