1055 lines
37 KiB
Rust
1055 lines
37 KiB
Rust
// 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);
|
||
}
|
||
}
|