montana/Montana-Protocol/Code/crates/mt-entry/src/lib.rs

1055 lines
37 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 Code/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);
}
}