1781 lines
64 KiB
Rust
1781 lines
64 KiB
Rust
|
|
// spec, разделы "BundledConfirmation", "VDF Reveal и лотерея", "Signed scope, identity и aggregation" (Правила R1, R2)
|
|||
|
|
|
|||
|
|
use mt_codec::{domain, write_bytes, write_u16, write_u64, CanonicalEncode};
|
|||
|
|
use mt_crypto::{
|
|||
|
|
hash, suite_id_from_u16, verify, Hash32, PublicKey, Signature, SuiteId, SIGNATURE_SIZE,
|
|||
|
|
};
|
|||
|
|
use mt_state::{NodeId, NodeTable};
|
|||
|
|
|
|||
|
|
// spec v33.1.5+: window_index унифицирован на u64 (8B LE) во всех M4 структурах
|
|||
|
|
// (BundledConfirmation, VdfReveal, ProposalHeader). Старый u32 (4B) убран как
|
|||
|
|
// architectural smell — единый тип для одного концептуально-единого field
|
|||
|
|
// устраняет cross-struct cast и расхождение в hash composition.
|
|||
|
|
pub const BUNDLE_FIXED_OVERHEAD: usize = 32 + 32 + 8 + 2 + 2 + SIGNATURE_SIZE;
|
|||
|
|
pub const REVEAL_SIZE: usize = 32 + 8 + 32 + SIGNATURE_SIZE;
|
|||
|
|
|
|||
|
|
// Early-warning threshold для bundle hash counts (closure M-M4-1 partial,
|
|||
|
|
// per внешний аудит claude-opus-4-7-1m_2026-04-28_T2023).
|
|||
|
|
//
|
|||
|
|
// u16 length prefix даёт hard cap 65 535 hashes; на 1B пользователей при
|
|||
|
|
// ~1000 узлах одно окно может приближаться к этому пределу. Threshold = 50%
|
|||
|
|
// (32 768) — early signal для оператора / mt-telemetry чтобы M6+ spec-patch
|
|||
|
|
// u16→u32 был запланирован ДО реального достижения hard reject.
|
|||
|
|
//
|
|||
|
|
// Lib остаётся silent (no eprintln, [I-3] / [C-6] cleanliness): caller
|
|||
|
|
// (montana-node либо mt-telemetry F-5) применяет should_warn_*_count() в
|
|||
|
|
// собственном logging path. validate_bundle продолжает hard reject ровно на
|
|||
|
|
// u16::MAX — early-warning не меняет consensus semantics.
|
|||
|
|
pub const HASH_COUNT_EARLY_WARNING_THRESHOLD: usize = 32_768;
|
|||
|
|
|
|||
|
|
#[inline]
|
|||
|
|
pub fn should_warn_op_hashes_count(count: usize) -> bool {
|
|||
|
|
count >= HASH_COUNT_EARLY_WARNING_THRESHOLD
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[inline]
|
|||
|
|
pub fn should_warn_reveal_hashes_count(count: usize) -> bool {
|
|||
|
|
count >= HASH_COUNT_EARLY_WARNING_THRESHOLD
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|||
|
|
pub struct BundledConfirmation {
|
|||
|
|
pub node_id: NodeId,
|
|||
|
|
pub endpoint: Hash32,
|
|||
|
|
pub window_index: u64,
|
|||
|
|
pub op_hashes: Vec<Hash32>,
|
|||
|
|
pub reveal_hashes: Vec<Hash32>,
|
|||
|
|
pub signature: Signature,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl BundledConfirmation {
|
|||
|
|
// spec: Правило R1 — signed_scope = canonical_bytes без поля signature (last SIGNATURE_SIZE bytes)
|
|||
|
|
//
|
|||
|
|
// Wire format: длины op_hashes/reveal_hashes как u16 LE prefixes (per spec).
|
|||
|
|
// Caller responsibility: validate_bundle проверяет op_hashes.len() ≤ u16::MAX
|
|||
|
|
// ДО encode (M4-1 closure). debug_assert catch silent truncation если caller
|
|||
|
|
// обходит validate_bundle в debug builds; в release полагается на validate
|
|||
|
|
// gating (signature verify косвенно catches encode mismatch).
|
|||
|
|
pub fn encode_signed_scope(&self, buf: &mut Vec<u8>) {
|
|||
|
|
debug_assert!(
|
|||
|
|
self.op_hashes.len() <= u16::MAX as usize,
|
|||
|
|
"BundledConfirmation.op_hashes.len() = {} > u16::MAX; \
|
|||
|
|
caller обязан validate_bundle ДО encode (M4-1 invariant)",
|
|||
|
|
self.op_hashes.len()
|
|||
|
|
);
|
|||
|
|
debug_assert!(
|
|||
|
|
self.reveal_hashes.len() <= u16::MAX as usize,
|
|||
|
|
"BundledConfirmation.reveal_hashes.len() = {} > u16::MAX; \
|
|||
|
|
caller обязан validate_bundle ДО encode (M4-1 invariant)",
|
|||
|
|
self.reveal_hashes.len()
|
|||
|
|
);
|
|||
|
|
write_bytes(buf, &self.node_id);
|
|||
|
|
write_bytes(buf, &self.endpoint);
|
|||
|
|
write_u64(buf, self.window_index);
|
|||
|
|
write_u16(buf, self.op_hashes.len() as u16);
|
|||
|
|
for h in &self.op_hashes {
|
|||
|
|
write_bytes(buf, h);
|
|||
|
|
}
|
|||
|
|
write_u16(buf, self.reveal_hashes.len() as u16);
|
|||
|
|
for h in &self.reveal_hashes {
|
|||
|
|
write_bytes(buf, h);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl CanonicalEncode for BundledConfirmation {
|
|||
|
|
fn encode(&self, buf: &mut Vec<u8>) {
|
|||
|
|
self.encode_signed_scope(buf);
|
|||
|
|
write_bytes(buf, self.signature.as_bytes());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// spec: Правило R2 — bundle_hash = SHA-256("mt-bundle" || signed_scope(bundle))
|
|||
|
|
pub fn bundle_hash(bc: &BundledConfirmation) -> Hash32 {
|
|||
|
|
let mut scope = Vec::new();
|
|||
|
|
bc.encode_signed_scope(&mut scope);
|
|||
|
|
hash(domain::BUNDLE, &[&scope])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|||
|
|
pub enum BundleError {
|
|||
|
|
UnknownNode,
|
|||
|
|
UnsupportedSuite,
|
|||
|
|
OpsOutOfOrder,
|
|||
|
|
RevealsOutOfOrder,
|
|||
|
|
WrongEndpoint,
|
|||
|
|
InvalidSignature,
|
|||
|
|
// M4-1 closure: encode_signed_scope использует write_u16 для длины
|
|||
|
|
// op_hashes/reveal_hashes (spec wire format). При len > u16::MAX = 65535
|
|||
|
|
// cast `as u16` молча truncates → silent encode/decode mismatch. Защита
|
|||
|
|
// через signature verify косвенная (mismatch → wrong scope hash → fail);
|
|||
|
|
// explicit invariant ловит это до encode/sign и даёт чёткий error path.
|
|||
|
|
TooManyOps,
|
|||
|
|
TooManyReveals,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// spec: "op_hashes[] ascending lexicographic" + "reveal_hashes[] ascending lexicographic"
|
|||
|
|
fn is_strictly_ascending(items: &[Hash32]) -> bool {
|
|||
|
|
items.windows(2).all(|w| w[0] < w[1])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// spec, раздел "BundledConfirmation" — валидация перед inclusion в cemented set.
|
|||
|
|
// Проверки: (a) node зарегистрирован и подписывает ML-DSA-65,
|
|||
|
|
// (b) endpoint == T_r текущего окна (caller вычисляет canonical T_r),
|
|||
|
|
// (c) op_hashes[] / reveal_hashes[] строго возрастают,
|
|||
|
|
// (d) signature верифицируется против NodeTable[node_id].node_pubkey над signed_scope.
|
|||
|
|
pub fn validate_bundle(
|
|||
|
|
bc: &BundledConfirmation,
|
|||
|
|
node_table: &NodeTable,
|
|||
|
|
expected_endpoint: &Hash32,
|
|||
|
|
) -> Result<(), BundleError> {
|
|||
|
|
let node = node_table
|
|||
|
|
.get(&bc.node_id)
|
|||
|
|
.ok_or(BundleError::UnknownNode)?;
|
|||
|
|
match suite_id_from_u16(node.suite_id) {
|
|||
|
|
Some(SuiteId::Mldsa65) => {},
|
|||
|
|
None => return Err(BundleError::UnsupportedSuite),
|
|||
|
|
}
|
|||
|
|
if bc.endpoint != *expected_endpoint {
|
|||
|
|
return Err(BundleError::WrongEndpoint);
|
|||
|
|
}
|
|||
|
|
// M4-1 closure: explicit caps на длину Vec'ов перед encode (write_u16 cast).
|
|||
|
|
// Защита от silent truncation; reject ДО signature verify чтобы не тратить
|
|||
|
|
// ML-DSA-65 verify cycles на guaranteed-broken bundle.
|
|||
|
|
// M4-1 hard limit: wire format кодирует длину как u16 LE → ≤ 65535 hashes.
|
|||
|
|
// SCALE NOTE (M6+ scaling concern): на 1B активных пользователей при
|
|||
|
|
// ~1000 узлах одно окно может содержать > 100K операций per node →
|
|||
|
|
// bundle с op_hashes > 65535 не поместится в текущий wire format.
|
|||
|
|
// Эскалация требует spec-patch на u16→u32 length prefix.
|
|||
|
|
if bc.op_hashes.len() > u16::MAX as usize {
|
|||
|
|
return Err(BundleError::TooManyOps);
|
|||
|
|
}
|
|||
|
|
if bc.reveal_hashes.len() > u16::MAX as usize {
|
|||
|
|
return Err(BundleError::TooManyReveals);
|
|||
|
|
}
|
|||
|
|
if !is_strictly_ascending(&bc.op_hashes) {
|
|||
|
|
return Err(BundleError::OpsOutOfOrder);
|
|||
|
|
}
|
|||
|
|
if !is_strictly_ascending(&bc.reveal_hashes) {
|
|||
|
|
return Err(BundleError::RevealsOutOfOrder);
|
|||
|
|
}
|
|||
|
|
let mut scope = Vec::new();
|
|||
|
|
bc.encode_signed_scope(&mut scope);
|
|||
|
|
let pk = PublicKey::from_array(node.node_pubkey);
|
|||
|
|
if !verify(&pk, &scope, &bc.signature) {
|
|||
|
|
return Err(BundleError::InvalidSignature);
|
|||
|
|
}
|
|||
|
|
Ok(())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// spec, раздел "VDF Reveal и лотерея" (строки 920-928)
|
|||
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|||
|
|
pub struct VdfReveal {
|
|||
|
|
pub node_id: NodeId,
|
|||
|
|
pub window_index: u64,
|
|||
|
|
pub endpoint: Hash32,
|
|||
|
|
pub signature: Signature,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl VdfReveal {
|
|||
|
|
// spec: Правило R1 — signed_scope = canonical_bytes без поля signature (last SIGNATURE_SIZE bytes)
|
|||
|
|
pub fn encode_signed_scope(&self, buf: &mut Vec<u8>) {
|
|||
|
|
write_bytes(buf, &self.node_id);
|
|||
|
|
write_u64(buf, self.window_index);
|
|||
|
|
write_bytes(buf, &self.endpoint);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl CanonicalEncode for VdfReveal {
|
|||
|
|
fn encode(&self, buf: &mut Vec<u8>) {
|
|||
|
|
self.encode_signed_scope(buf);
|
|||
|
|
write_bytes(buf, self.signature.as_bytes());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// spec: Правило R2 — reveal_hash = SHA-256("mt-vdf-reveal" || signed_scope(reveal))
|
|||
|
|
pub fn reveal_hash(reveal: &VdfReveal) -> Hash32 {
|
|||
|
|
let mut scope = Vec::new();
|
|||
|
|
reveal.encode_signed_scope(&mut scope);
|
|||
|
|
hash(domain::VDF_REVEAL, &[&scope])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// spec, "VDF Reveal и лотерея" — endpoint formula:
|
|||
|
|
// endpoint_node(W) = SHA-256("mt-lottery" || T_r(W) || cemented_bundle_aggregate(W-2) || node_id || window_index)
|
|||
|
|
// window_index encoded as u64 LE (8B) consistent с layout field (spec v33.1.5+ unified).
|
|||
|
|
pub fn compute_endpoint(
|
|||
|
|
t_r: &Hash32,
|
|||
|
|
cemented_bundle_aggregate_w_minus_2: &Hash32,
|
|||
|
|
node_id: &NodeId,
|
|||
|
|
window_index: u64,
|
|||
|
|
) -> Hash32 {
|
|||
|
|
let mut w_le = Vec::with_capacity(8);
|
|||
|
|
write_u64(&mut w_le, window_index);
|
|||
|
|
hash(
|
|||
|
|
domain::LOTTERY,
|
|||
|
|
&[t_r, cemented_bundle_aggregate_w_minus_2, node_id, &w_le],
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|||
|
|
pub enum RevealError {
|
|||
|
|
UnknownNode,
|
|||
|
|
UnsupportedSuite,
|
|||
|
|
WrongWindow,
|
|||
|
|
WrongEndpoint,
|
|||
|
|
InvalidSignature,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// spec, "Валидация VDF_Reveal" (строки 1020-1026) — правила 1, 2, 3, 5.
|
|||
|
|
// Правило 4 (weighted_ticket < target) — в Phase C (Node lottery).
|
|||
|
|
pub fn validate_reveal(
|
|||
|
|
reveal: &VdfReveal,
|
|||
|
|
node_table: &NodeTable,
|
|||
|
|
t_r: &Hash32,
|
|||
|
|
cemented_bundle_aggregate_w_minus_2: &Hash32,
|
|||
|
|
current_window: u64,
|
|||
|
|
) -> Result<(), RevealError> {
|
|||
|
|
let node = node_table
|
|||
|
|
.get(&reveal.node_id)
|
|||
|
|
.ok_or(RevealError::UnknownNode)?;
|
|||
|
|
match suite_id_from_u16(node.suite_id) {
|
|||
|
|
Some(SuiteId::Mldsa65) => {},
|
|||
|
|
None => return Err(RevealError::UnsupportedSuite),
|
|||
|
|
}
|
|||
|
|
if reveal.window_index != current_window {
|
|||
|
|
return Err(RevealError::WrongWindow);
|
|||
|
|
}
|
|||
|
|
let expected_endpoint = compute_endpoint(
|
|||
|
|
t_r,
|
|||
|
|
cemented_bundle_aggregate_w_minus_2,
|
|||
|
|
&reveal.node_id,
|
|||
|
|
reveal.window_index,
|
|||
|
|
);
|
|||
|
|
if reveal.endpoint != expected_endpoint {
|
|||
|
|
return Err(RevealError::WrongEndpoint);
|
|||
|
|
}
|
|||
|
|
let mut scope = Vec::new();
|
|||
|
|
reveal.encode_signed_scope(&mut scope);
|
|||
|
|
let pk = PublicKey::from_array(node.node_pubkey);
|
|||
|
|
if !verify(&pk, &scope, &reveal.signature) {
|
|||
|
|
return Err(RevealError::InvalidSignature);
|
|||
|
|
}
|
|||
|
|
Ok(())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============ Phase C: Node lottery (per [I-9]) ============
|
|||
|
|
|
|||
|
|
// spec, раздел "Класс 1: узлы" + "Integer log algorithm (per [I-9])".
|
|||
|
|
// Conformance status: closed (binding coefficients B0..B3 + 5 test vectors в спеке).
|
|||
|
|
|
|||
|
|
// spec: seniority_term = min(chain_length / 13, chain_length_snapshot).
|
|||
|
|
// Целочисленное деление unsigned u64 (truncation toward zero): chain_length < 13
|
|||
|
|
// ⇒ seniority_term = 0 (первые 13 окон после регистрации).
|
|||
|
|
// Делитель 13 — derivation: target T_cap = 3 × T_year ≈ 1 577 880 окон,
|
|||
|
|
// snapshot_max = 6τ₂ = 120 960, divisor = 1 577 880 / 120 960 ≈ 13.
|
|||
|
|
// Structural reuse: совпадает с EMISSION_moneta = 13 Ɉ per window.
|
|||
|
|
pub fn seniority_term(chain_length: u64, chain_length_snapshot: u64) -> u64 {
|
|||
|
|
(chain_length / 13).min(chain_length_snapshot)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// spec: lottery_weight = chain_length_snapshot + seniority_term.
|
|||
|
|
// Инвариант DS-2: при chain_length_snapshot ≥ 1 (что гарантировано для active
|
|||
|
|
// узлов через pruning/active_predicate chain) → lottery_weight ≥ 1.
|
|||
|
|
// Overflow: snapshot ≤ 120960 (6τ₂), seniority ≤ snapshot, sum ≤ 241920 ⇒ safe u64.
|
|||
|
|
pub fn lottery_weight(chain_length: u64, chain_length_snapshot: u64) -> u64 {
|
|||
|
|
chain_length_snapshot + seniority_term(chain_length, chain_length_snapshot)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// LN(2) в Q64.64: ln(2) × 2^64 ≈ 12786308645202655659 (truncated toward zero).
|
|||
|
|
// spec binding constant LN2_Q64.
|
|||
|
|
const LN2_Q64: u128 = 0xB172_17F7_D1CF_79AB;
|
|||
|
|
|
|||
|
|
// spec binding coefficients (halved polynomial form, все unsigned u64 Q64).
|
|||
|
|
// p(y) = log2(1+y) × 2^64 ≈ (B0 + y·(B1 - y·(B2_ABS - y·B3))) << 1
|
|||
|
|
// a1 > 1 не поместился бы в u64 при полном Q64 — halved form обходит через
|
|||
|
|
// division by 2 и финальный left shift.
|
|||
|
|
const B0: u64 = 0x0014_E086_EC98_2D63;
|
|||
|
|
const B1: u64 = 0xB59D_DDE5_2A69_D000;
|
|||
|
|
const B2_ABS: u64 = 0x49DF_5C3B_FD9C_EC00;
|
|||
|
|
const B3: u64 = 0x1441_7E56_D333_1800;
|
|||
|
|
|
|||
|
|
// spec: log2_q64(endpoint) → Q64.64 u128 representation of log2(2^256 / endpoint).
|
|||
|
|
// Monotonic: меньший endpoint → больший log2_q64.
|
|||
|
|
// endpoint == 0 (вероятность SHA-collision) клипируется до u128::MAX.
|
|||
|
|
//
|
|||
|
|
// Binding: degree-3 Remez minimax polynomial, максимальная ошибка 2^-10.62.
|
|||
|
|
// [I-8] reconciliation: approximation error grinding advantage bounded через
|
|||
|
|
// cemented_bundle_aggregate(W-2) в endpoint formula — см. спеку «Integer log
|
|||
|
|
// algorithm» раздел «[I-8] reconciliation».
|
|||
|
|
pub fn log2_q64(endpoint: &Hash32) -> u128 {
|
|||
|
|
// Count leading zeros of u256 big-endian.
|
|||
|
|
let mut leading: u32 = 0;
|
|||
|
|
let mut all_zero = true;
|
|||
|
|
for b in endpoint.iter() {
|
|||
|
|
if *b == 0 {
|
|||
|
|
leading += 8;
|
|||
|
|
} else {
|
|||
|
|
leading += b.leading_zeros();
|
|||
|
|
all_zero = false;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if all_zero {
|
|||
|
|
// endpoint == 0, log2(∞), saturate
|
|||
|
|
return u128::MAX;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// int_part = leading_zeros = log2(2^256 / endpoint) integer part (floor).
|
|||
|
|
let int_part = leading as u128;
|
|||
|
|
|
|||
|
|
// Extract u128 mantissa in [2^127, 2^128) from big-endian u256.
|
|||
|
|
// MSB position in [0, 255] = 255 - leading.
|
|||
|
|
// Split endpoint в две u128 halves (high = bits 128..255, low = bits 0..127).
|
|||
|
|
// M4-LOW-3 closure: unwrap_or([0; 16]) вместо expect — структурно
|
|||
|
|
// unreachable (slice [0..16] от [u8; 32] всегда длиной 16, try_into для
|
|||
|
|
// [u8; 16] не fails), но absolute panic-free guarantee предпочтительнее
|
|||
|
|
// controlled panic при protocol invariant breach. На impossible path
|
|||
|
|
// fallback даёт all-zeros half, log2 returns deterministic 0 (вместо
|
|||
|
|
// unrecoverable panic). External audit T141253 [LOW].
|
|||
|
|
let mut hi_bytes = [0u8; 16];
|
|||
|
|
hi_bytes.copy_from_slice(&endpoint[0..16]);
|
|||
|
|
let e_hi = u128::from_be_bytes(hi_bytes);
|
|||
|
|
let mut lo_bytes = [0u8; 16];
|
|||
|
|
lo_bytes.copy_from_slice(&endpoint[16..32]);
|
|||
|
|
let e_lo = u128::from_be_bytes(lo_bytes);
|
|||
|
|
|
|||
|
|
let msb_position: u32 = 255 - leading; // ∈ [0, 255]
|
|||
|
|
let mantissa: u128 = if msb_position >= 128 {
|
|||
|
|
// e_hi != 0. Right-shift u256 by (msb_position - 127) ∈ [1, 128].
|
|||
|
|
let shift = msb_position - 127;
|
|||
|
|
if shift < 128 {
|
|||
|
|
(e_hi << (128 - shift)) | (e_lo >> shift)
|
|||
|
|
} else {
|
|||
|
|
// shift == 128: low 128 bits of (e_hi:e_lo >> 128) = e_hi
|
|||
|
|
e_hi
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// e_hi == 0. msb in e_lo at bit msb_position ≤ 127.
|
|||
|
|
// Left-shift e_lo by (127 - msb_position) ∈ [0, 127].
|
|||
|
|
let shift = 127 - msb_position;
|
|||
|
|
e_lo << shift
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// x_q64 = y × 2^64 где y = mantissa/2^127 - 1 ∈ [0, 1).
|
|||
|
|
let x_q64 = ((mantissa - (1u128 << 127)) >> 63) as u64;
|
|||
|
|
|
|||
|
|
// Unsigned Horner evaluation half_p(y) = B0 + y·(B1 - y·(B2_ABS - y·B3)).
|
|||
|
|
// Все intermediate shifts u64; intermediate invariants доказаны non-negative в спеке.
|
|||
|
|
let t1 = (((B3 as u128) * (x_q64 as u128)) >> 64) as u64; // y·B3 ≤ B3 < B2_ABS
|
|||
|
|
debug_assert!(t1 <= B2_ABS);
|
|||
|
|
let t2 = B2_ABS - t1;
|
|||
|
|
let t3 = (((t2 as u128) * (x_q64 as u128)) >> 64) as u64;
|
|||
|
|
debug_assert!(t3 <= B1);
|
|||
|
|
let t4 = B1 - t3;
|
|||
|
|
let t5 = (((t4 as u128) * (x_q64 as u128)) >> 64) as u64;
|
|||
|
|
let half_p = B0 + t5; // < 2^63 + ε
|
|||
|
|
let frac_q64 = (half_p as u128) << 1; // ≈ log2(1+y) × 2^64
|
|||
|
|
|
|||
|
|
// log2(2^256/e) = (leading + 1) - log2(1+y). При y→1 minimax approximation
|
|||
|
|
// может незначительно превысить 1 (minimax equal positive/negative errors) —
|
|||
|
|
// saturating_sub обеспечивает результат ≥ 0 (log2 от числа ≥ 1 не отрицательно).
|
|||
|
|
((int_part + 1) << 64).saturating_sub(frac_q64)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// spec: ticket = ln_q64(endpoint) = -ln(endpoint/2^256) × 2^64.
|
|||
|
|
// Computed as log2_q64 × LN2_Q64 / 2^64.
|
|||
|
|
// Monotonic: меньший endpoint → больший ln_q64.
|
|||
|
|
pub fn ln_q64(endpoint: &Hash32) -> u128 {
|
|||
|
|
let log2 = log2_q64(endpoint);
|
|||
|
|
if log2 == u128::MAX {
|
|||
|
|
return u128::MAX; // endpoint == 0 saturates through
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// log2 < 2^72 (256 × 2^64 bound); LN2 < 2^64.
|
|||
|
|
// Product fits in u192; need >> 64. Split log2 в high/low 64-bit halves.
|
|||
|
|
let log2_high = (log2 >> 64) as u64;
|
|||
|
|
let log2_low = log2 as u64;
|
|||
|
|
|
|||
|
|
// term_high = log2_high × LN2 (u64 × u128 → u128, LN2 < 2^64 ⇒ safe)
|
|||
|
|
let term_high = (log2_high as u128) * LN2_Q64;
|
|||
|
|
// term_low = (log2_low × LN2) >> 64
|
|||
|
|
let term_low = ((log2_low as u128) * LN2_Q64) >> 64;
|
|||
|
|
|
|||
|
|
term_high.saturating_add(term_low)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// spec, раздел "Класс 1: узлы" integer form.
|
|||
|
|
// weighted_ticket_node = ln_q64(endpoint) / (lottery_weight as u128).
|
|||
|
|
// Precondition: chain_length_snapshot ≥ 1 (DS-2 invariant; caller enforces).
|
|||
|
|
// Integer division toward zero (unsigned u128 / u128).
|
|||
|
|
pub fn weighted_ticket_node(
|
|||
|
|
endpoint: &Hash32,
|
|||
|
|
chain_length: u64,
|
|||
|
|
chain_length_snapshot: u64,
|
|||
|
|
) -> u128 {
|
|||
|
|
let w = lottery_weight(chain_length, chain_length_snapshot);
|
|||
|
|
// DS-2 gate: w ≥ 1. Если нарушено — protocol violation (spec строка 924).
|
|||
|
|
// Здесь защищаемся от panic: при w == 0 возвращаем u128::MAX (неверный
|
|||
|
|
// winner in argmin, но не crash — caller должен validate до вызова).
|
|||
|
|
if w == 0 {
|
|||
|
|
return u128::MAX;
|
|||
|
|
}
|
|||
|
|
ln_q64(endpoint) / (w as u128)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============ Phase D: Account lottery УДАЛЕНА ============
|
|||
|
|
//
|
|||
|
|
// spec Sovereignty Ladder: лотерея single-class. compute_account_endpoint,
|
|||
|
|
// weighted_ticket_account, AccountLotteryError, validate_account_participation,
|
|||
|
|
// domain `mt-account-lottery`, 4 binding test vectors A1-A4 удалены.
|
|||
|
|
|
|||
|
|
// ============ Phase E: Winner determination (argmin) ============
|
|||
|
|
|
|||
|
|
// spec, раздел "Определение winner-а (Lookback Leadership)" (строки ~1017-1056):
|
|||
|
|
// winner_{W-1} = argmin(weighted_ticket) среди cemented VDF_Reveal nodes + accounts.
|
|||
|
|
//
|
|||
|
|
// [C-1] SSOT: единый источник WINNER_CLASS_NODE — mt-state.
|
|||
|
|
// Лотерея single-class: только узлы (spec Sovereignty Ladder);
|
|||
|
|
// account lottery удалена, значение `2` зарезервировано для будущих расширений.
|
|||
|
|
pub use mt_state::WINNER_CLASS_NODE;
|
|||
|
|
|
|||
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|||
|
|
pub struct Candidate {
|
|||
|
|
pub ticket: u128, // weighted_ticket Q64.64 (u128)
|
|||
|
|
pub class: u8, // WINNER_CLASS_NODE (единственное valid значение текущей схемы)
|
|||
|
|
pub id: [u8; 32], // node_id
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|||
|
|
pub struct Winner {
|
|||
|
|
pub class: u8,
|
|||
|
|
pub id: [u8; 32],
|
|||
|
|
pub ticket: u128,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// spec, "argmin(weighted_ticket)" — минимизация по основному ключу.
|
|||
|
|
// Tie-breaking (spec ambiguity, canonical rule введён здесь):
|
|||
|
|
// 1. ticket ascending
|
|||
|
|
// 2. class ascending (Node=1 < Account=2) — node preference при tie
|
|||
|
|
// 3. id lex ascending (32-byte byte-wise compare)
|
|||
|
|
// Probability ticket-tie ~ 2^-128; правило нужно для byte-exact determinism.
|
|||
|
|
pub fn determine_winner(candidates: &[Candidate]) -> Option<Winner> {
|
|||
|
|
candidates
|
|||
|
|
.iter()
|
|||
|
|
.min_by(|a, b| {
|
|||
|
|
a.ticket
|
|||
|
|
.cmp(&b.ticket)
|
|||
|
|
.then_with(|| a.class.cmp(&b.class))
|
|||
|
|
.then_with(|| a.id.cmp(&b.id))
|
|||
|
|
})
|
|||
|
|
.map(|c| Winner {
|
|||
|
|
class: c.class,
|
|||
|
|
id: c.id,
|
|||
|
|
ticket: c.ticket,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// spec, раздел "Определение winner-а" + "fallback cascade" (строка 1052):
|
|||
|
|
// fallback_proposer_W = second_min, third_min, etc.
|
|||
|
|
// Возвращает sorted vector кандидатов по той же canonical rule.
|
|||
|
|
// Caller берёт [0] для winner, [1] для fallback_1, и т.д.
|
|||
|
|
pub fn sorted_candidates_for_fallback(candidates: &[Candidate]) -> Vec<Candidate> {
|
|||
|
|
let mut sorted: Vec<Candidate> = candidates.to_vec();
|
|||
|
|
sorted.sort_by(|a, b| {
|
|||
|
|
a.ticket
|
|||
|
|
.cmp(&b.ticket)
|
|||
|
|
.then_with(|| a.class.cmp(&b.class))
|
|||
|
|
.then_with(|| a.id.cmp(&b.id))
|
|||
|
|
});
|
|||
|
|
sorted
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============ Phase F: Quorum calculation ============
|
|||
|
|
|
|||
|
|
// spec, раздел "Confirmation cutoff" (строка 1433 + integer form):
|
|||
|
|
// quorum(W) = ⌈0.67 × active_chain_length(W)⌉ (real-valued commentary)
|
|||
|
|
// quorum(W) = (67 × active_chain_length + 99) / 100 (integer, authoritative [I-9])
|
|||
|
|
// Unsigned u64. spec bound active ≤ 10^14: 67 × 10^14 + 99 < 2^63 — safe.
|
|||
|
|
//
|
|||
|
|
// M4-LOW-5 closure: saturating_mul/saturating_add защищают от u64::MAX
|
|||
|
|
// overflow (active_chain_length > u64::MAX/67 ≈ 2.7×10^17, нерелевантно
|
|||
|
|
// практически но defense-in-depth: на overflow возвращаем graceful
|
|||
|
|
// u64::MAX/100 вместо silent wrap либо panic в debug build).
|
|||
|
|
pub fn quorum(active_chain_length: u64) -> u64 {
|
|||
|
|
let scaled = 67u64.saturating_mul(active_chain_length);
|
|||
|
|
scaled.saturating_add(99) / 100
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// spec: объект cemented когда cemented_sum ≥ quorum(W).
|
|||
|
|
pub fn is_cemented(cemented_sum: u64, active_chain_length: u64) -> bool {
|
|||
|
|
cemented_sum >= quorum(active_chain_length)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[cfg(test)]
|
|||
|
|
mod tests {
|
|||
|
|
use super::*;
|
|||
|
|
use mt_crypto::{keypair, sign, SECRET_KEY_SIZE, SIGNATURE_SIZE};
|
|||
|
|
use mt_state::{derive_node_id, NodeRecord};
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn early_warning_threshold_is_half_u16_max() {
|
|||
|
|
// 32 768 = ровно 50% от u16::MAX (65 535) + 1; даёт ~33K headroom
|
|||
|
|
// оператору перед hard reject в validate_bundle.
|
|||
|
|
assert_eq!(HASH_COUNT_EARLY_WARNING_THRESHOLD, 32_768);
|
|||
|
|
assert!(HASH_COUNT_EARLY_WARNING_THRESHOLD < u16::MAX as usize);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn warn_helpers_trigger_at_threshold_boundary() {
|
|||
|
|
assert!(!should_warn_op_hashes_count(0));
|
|||
|
|
assert!(!should_warn_op_hashes_count(
|
|||
|
|
HASH_COUNT_EARLY_WARNING_THRESHOLD - 1
|
|||
|
|
));
|
|||
|
|
assert!(should_warn_op_hashes_count(
|
|||
|
|
HASH_COUNT_EARLY_WARNING_THRESHOLD
|
|||
|
|
));
|
|||
|
|
assert!(should_warn_op_hashes_count(u16::MAX as usize));
|
|||
|
|
|
|||
|
|
assert!(!should_warn_reveal_hashes_count(0));
|
|||
|
|
assert!(!should_warn_reveal_hashes_count(
|
|||
|
|
HASH_COUNT_EARLY_WARNING_THRESHOLD - 1
|
|||
|
|
));
|
|||
|
|
assert!(should_warn_reveal_hashes_count(
|
|||
|
|
HASH_COUNT_EARLY_WARNING_THRESHOLD
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn make_node(
|
|||
|
|
pubkey: [u8; mt_crypto::PUBLIC_KEY_SIZE],
|
|||
|
|
start_window: u64,
|
|||
|
|
) -> (NodeId, NodeRecord) {
|
|||
|
|
let node_id = derive_node_id(&pubkey);
|
|||
|
|
let rec = NodeRecord {
|
|||
|
|
node_id,
|
|||
|
|
node_pubkey: pubkey,
|
|||
|
|
suite_id: SuiteId::Mldsa65 as u16,
|
|||
|
|
operator_account_id: [0x11; 32],
|
|||
|
|
start_window,
|
|||
|
|
chain_length: 1,
|
|||
|
|
chain_length_snapshot: 1,
|
|||
|
|
chain_length_checkpoints: [1; 6],
|
|||
|
|
last_confirmation_window: start_window,
|
|||
|
|
};
|
|||
|
|
(node_id, rec)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn build_signed_bc(
|
|||
|
|
sk: &mt_crypto::SecretKey,
|
|||
|
|
node_id: NodeId,
|
|||
|
|
endpoint: Hash32,
|
|||
|
|
window_index: u64,
|
|||
|
|
op_hashes: Vec<Hash32>,
|
|||
|
|
reveal_hashes: Vec<Hash32>,
|
|||
|
|
) -> BundledConfirmation {
|
|||
|
|
let mut bc = BundledConfirmation {
|
|||
|
|
node_id,
|
|||
|
|
endpoint,
|
|||
|
|
window_index,
|
|||
|
|
op_hashes,
|
|||
|
|
reveal_hashes,
|
|||
|
|
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
|
|||
|
|
};
|
|||
|
|
let mut scope = Vec::new();
|
|||
|
|
bc.encode_signed_scope(&mut scope);
|
|||
|
|
bc.signature = sign(sk, &scope).expect("sign BundledConfirmation scope");
|
|||
|
|
bc
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn encode_matches_spec_layout() {
|
|||
|
|
let bc = BundledConfirmation {
|
|||
|
|
node_id: [0xAA; 32],
|
|||
|
|
endpoint: [0xBB; 32],
|
|||
|
|
window_index: 0x0102030405060708,
|
|||
|
|
op_hashes: vec![[0x11; 32], [0x22; 32]],
|
|||
|
|
reveal_hashes: vec![[0x33; 32]],
|
|||
|
|
signature: Signature::from_array([0x44; SIGNATURE_SIZE]),
|
|||
|
|
};
|
|||
|
|
let mut buf = Vec::new();
|
|||
|
|
bc.encode(&mut buf);
|
|||
|
|
|
|||
|
|
let expected_size = BUNDLE_FIXED_OVERHEAD + 32 * 2 + 32;
|
|||
|
|
assert_eq!(buf.len(), expected_size);
|
|||
|
|
|
|||
|
|
// node_id: 0..32
|
|||
|
|
assert_eq!(&buf[0..32], &[0xAA; 32]);
|
|||
|
|
// endpoint: 32..64
|
|||
|
|
assert_eq!(&buf[32..64], &[0xBB; 32]);
|
|||
|
|
// window_index u64 LE: 64..72 (spec v33.1.5+ — было u32 4B до v33.1.5)
|
|||
|
|
assert_eq!(
|
|||
|
|
&buf[64..72],
|
|||
|
|
&[0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01]
|
|||
|
|
);
|
|||
|
|
// op_count LE: 72..74 = 2
|
|||
|
|
assert_eq!(&buf[72..74], &[0x02, 0x00]);
|
|||
|
|
// op_hashes: 74..106 (first), 106..138 (second)
|
|||
|
|
assert_eq!(&buf[74..106], &[0x11; 32]);
|
|||
|
|
assert_eq!(&buf[106..138], &[0x22; 32]);
|
|||
|
|
// reveal_count LE: 138..140 = 1
|
|||
|
|
assert_eq!(&buf[138..140], &[0x01, 0x00]);
|
|||
|
|
// reveal_hashes: 140..172
|
|||
|
|
assert_eq!(&buf[140..172], &[0x33; 32]);
|
|||
|
|
// signature: last SIGNATURE_SIZE bytes (3309 для ML-DSA-65)
|
|||
|
|
assert_eq!(&buf[172..172 + SIGNATURE_SIZE], &[0x44; SIGNATURE_SIZE]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn encode_empty_bundle_fixed_overhead() {
|
|||
|
|
let bc = BundledConfirmation {
|
|||
|
|
node_id: [0; 32],
|
|||
|
|
endpoint: [0; 32],
|
|||
|
|
window_index: 0,
|
|||
|
|
op_hashes: vec![],
|
|||
|
|
reveal_hashes: vec![],
|
|||
|
|
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
|
|||
|
|
};
|
|||
|
|
let mut buf = Vec::new();
|
|||
|
|
bc.encode(&mut buf);
|
|||
|
|
assert_eq!(buf.len(), BUNDLE_FIXED_OVERHEAD);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn signed_scope_excludes_signature() {
|
|||
|
|
let bc = BundledConfirmation {
|
|||
|
|
node_id: [0xAA; 32],
|
|||
|
|
endpoint: [0xBB; 32],
|
|||
|
|
window_index: 7,
|
|||
|
|
op_hashes: vec![[0x01; 32]],
|
|||
|
|
reveal_hashes: vec![],
|
|||
|
|
signature: Signature::from_array([0xCC; SIGNATURE_SIZE]),
|
|||
|
|
};
|
|||
|
|
let mut scope = Vec::new();
|
|||
|
|
bc.encode_signed_scope(&mut scope);
|
|||
|
|
let mut full = Vec::new();
|
|||
|
|
bc.encode(&mut full);
|
|||
|
|
assert_eq!(full.len(), scope.len() + SIGNATURE_SIZE);
|
|||
|
|
assert_eq!(&full[..scope.len()], scope.as_slice());
|
|||
|
|
assert_eq!(&full[scope.len()..], &[0xCC; SIGNATURE_SIZE]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn signed_scope_same_for_different_signatures() {
|
|||
|
|
// R2 свойство: identifier стабилен независимо от схемы подписи.
|
|||
|
|
let mut bc1 = BundledConfirmation {
|
|||
|
|
node_id: [0xAA; 32],
|
|||
|
|
endpoint: [0xBB; 32],
|
|||
|
|
window_index: 7,
|
|||
|
|
op_hashes: vec![],
|
|||
|
|
reveal_hashes: vec![],
|
|||
|
|
signature: Signature::from_array([0x00; SIGNATURE_SIZE]),
|
|||
|
|
};
|
|||
|
|
let mut scope1 = Vec::new();
|
|||
|
|
bc1.encode_signed_scope(&mut scope1);
|
|||
|
|
bc1.signature = Signature::from_array([0xFF; SIGNATURE_SIZE]);
|
|||
|
|
let mut scope2 = Vec::new();
|
|||
|
|
bc1.encode_signed_scope(&mut scope2);
|
|||
|
|
assert_eq!(scope1, scope2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn bundle_hash_domain_mt_bundle() {
|
|||
|
|
let bc = BundledConfirmation {
|
|||
|
|
node_id: [0x01; 32],
|
|||
|
|
endpoint: [0x02; 32],
|
|||
|
|
window_index: 1,
|
|||
|
|
op_hashes: vec![],
|
|||
|
|
reveal_hashes: vec![],
|
|||
|
|
signature: Signature::from_array([0x00; SIGNATURE_SIZE]),
|
|||
|
|
};
|
|||
|
|
let mut scope = Vec::new();
|
|||
|
|
bc.encode_signed_scope(&mut scope);
|
|||
|
|
let expected = hash(b"mt-bundle", &[&scope]);
|
|||
|
|
assert_eq!(bundle_hash(&bc), expected);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn bundle_hash_stable_across_resign() {
|
|||
|
|
// R2: identifier не зависит от signature
|
|||
|
|
let mut bc = BundledConfirmation {
|
|||
|
|
node_id: [0x01; 32],
|
|||
|
|
endpoint: [0x02; 32],
|
|||
|
|
window_index: 1,
|
|||
|
|
op_hashes: vec![[0xAB; 32]],
|
|||
|
|
reveal_hashes: vec![],
|
|||
|
|
signature: Signature::from_array([0x00; SIGNATURE_SIZE]),
|
|||
|
|
};
|
|||
|
|
let h1 = bundle_hash(&bc);
|
|||
|
|
bc.signature = Signature::from_array([0xCD; SIGNATURE_SIZE]);
|
|||
|
|
let h2 = bundle_hash(&bc);
|
|||
|
|
assert_eq!(h1, h2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn bundle_hash_changes_with_content() {
|
|||
|
|
let mut bc = BundledConfirmation {
|
|||
|
|
node_id: [0x01; 32],
|
|||
|
|
endpoint: [0x02; 32],
|
|||
|
|
window_index: 1,
|
|||
|
|
op_hashes: vec![],
|
|||
|
|
reveal_hashes: vec![],
|
|||
|
|
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
|
|||
|
|
};
|
|||
|
|
let h1 = bundle_hash(&bc);
|
|||
|
|
bc.op_hashes.push([0xAB; 32]);
|
|||
|
|
let h2 = bundle_hash(&bc);
|
|||
|
|
assert_ne!(h1, h2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn validate_accepts_valid_bundle() {
|
|||
|
|
let (pk, sk) = keypair();
|
|||
|
|
let (node_id, rec) = make_node(*pk.as_bytes(), 10);
|
|||
|
|
let mut nt = NodeTable::new();
|
|||
|
|
nt.insert(rec);
|
|||
|
|
let endpoint = [0x55; 32];
|
|||
|
|
let bc = build_signed_bc(
|
|||
|
|
&sk,
|
|||
|
|
node_id,
|
|||
|
|
endpoint,
|
|||
|
|
42,
|
|||
|
|
vec![[0x01; 32], [0x02; 32]],
|
|||
|
|
vec![[0x10; 32]],
|
|||
|
|
);
|
|||
|
|
assert_eq!(validate_bundle(&bc, &nt, &endpoint), Ok(()));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn validate_accepts_empty_ops_and_reveals() {
|
|||
|
|
let (pk, sk) = keypair();
|
|||
|
|
let (node_id, rec) = make_node(*pk.as_bytes(), 1);
|
|||
|
|
let mut nt = NodeTable::new();
|
|||
|
|
nt.insert(rec);
|
|||
|
|
let endpoint = [0x77; 32];
|
|||
|
|
let bc = build_signed_bc(&sk, node_id, endpoint, 5, vec![], vec![]);
|
|||
|
|
assert_eq!(validate_bundle(&bc, &nt, &endpoint), Ok(()));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn validate_rejects_unknown_node() {
|
|||
|
|
let (pk, sk) = keypair();
|
|||
|
|
let (node_id, _rec) = make_node(*pk.as_bytes(), 1);
|
|||
|
|
let nt = NodeTable::new(); // пустой
|
|||
|
|
let endpoint = [0; 32];
|
|||
|
|
let bc = build_signed_bc(&sk, node_id, endpoint, 1, vec![], vec![]);
|
|||
|
|
assert_eq!(
|
|||
|
|
validate_bundle(&bc, &nt, &endpoint),
|
|||
|
|
Err(BundleError::UnknownNode)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn validate_rejects_unsupported_suite() {
|
|||
|
|
let (pk, sk) = keypair();
|
|||
|
|
let (node_id, mut rec) = make_node(*pk.as_bytes(), 1);
|
|||
|
|
rec.suite_id = 0xFFFF; // не Mldsa65
|
|||
|
|
let mut nt = NodeTable::new();
|
|||
|
|
nt.insert(rec);
|
|||
|
|
let endpoint = [0; 32];
|
|||
|
|
let bc = build_signed_bc(&sk, node_id, endpoint, 1, vec![], vec![]);
|
|||
|
|
assert_eq!(
|
|||
|
|
validate_bundle(&bc, &nt, &endpoint),
|
|||
|
|
Err(BundleError::UnsupportedSuite)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn validate_rejects_wrong_endpoint() {
|
|||
|
|
let (pk, sk) = keypair();
|
|||
|
|
let (node_id, rec) = make_node(*pk.as_bytes(), 1);
|
|||
|
|
let mut nt = NodeTable::new();
|
|||
|
|
nt.insert(rec);
|
|||
|
|
let endpoint = [0xAA; 32];
|
|||
|
|
let expected = [0xBB; 32];
|
|||
|
|
let bc = build_signed_bc(&sk, node_id, endpoint, 1, vec![], vec![]);
|
|||
|
|
assert_eq!(
|
|||
|
|
validate_bundle(&bc, &nt, &expected),
|
|||
|
|
Err(BundleError::WrongEndpoint)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn validate_rejects_unsorted_ops() {
|
|||
|
|
let (pk, sk) = keypair();
|
|||
|
|
let (node_id, rec) = make_node(*pk.as_bytes(), 1);
|
|||
|
|
let mut nt = NodeTable::new();
|
|||
|
|
nt.insert(rec);
|
|||
|
|
let endpoint = [0; 32];
|
|||
|
|
let bc = build_signed_bc(
|
|||
|
|
&sk,
|
|||
|
|
node_id,
|
|||
|
|
endpoint,
|
|||
|
|
1,
|
|||
|
|
vec![[0x02; 32], [0x01; 32]], // обратный порядок
|
|||
|
|
vec![],
|
|||
|
|
);
|
|||
|
|
assert_eq!(
|
|||
|
|
validate_bundle(&bc, &nt, &endpoint),
|
|||
|
|
Err(BundleError::OpsOutOfOrder)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn validate_rejects_duplicate_ops() {
|
|||
|
|
let (pk, sk) = keypair();
|
|||
|
|
let (node_id, rec) = make_node(*pk.as_bytes(), 1);
|
|||
|
|
let mut nt = NodeTable::new();
|
|||
|
|
nt.insert(rec);
|
|||
|
|
let endpoint = [0; 32];
|
|||
|
|
// strict ascending — дубликаты отклоняются
|
|||
|
|
let bc = build_signed_bc(
|
|||
|
|
&sk,
|
|||
|
|
node_id,
|
|||
|
|
endpoint,
|
|||
|
|
1,
|
|||
|
|
vec![[0x01; 32], [0x01; 32]],
|
|||
|
|
vec![],
|
|||
|
|
);
|
|||
|
|
assert_eq!(
|
|||
|
|
validate_bundle(&bc, &nt, &endpoint),
|
|||
|
|
Err(BundleError::OpsOutOfOrder)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn validate_rejects_unsorted_reveals() {
|
|||
|
|
let (pk, sk) = keypair();
|
|||
|
|
let (node_id, rec) = make_node(*pk.as_bytes(), 1);
|
|||
|
|
let mut nt = NodeTable::new();
|
|||
|
|
nt.insert(rec);
|
|||
|
|
let endpoint = [0; 32];
|
|||
|
|
let bc = build_signed_bc(
|
|||
|
|
&sk,
|
|||
|
|
node_id,
|
|||
|
|
endpoint,
|
|||
|
|
1,
|
|||
|
|
vec![],
|
|||
|
|
vec![[0x99; 32], [0x11; 32]],
|
|||
|
|
);
|
|||
|
|
assert_eq!(
|
|||
|
|
validate_bundle(&bc, &nt, &endpoint),
|
|||
|
|
Err(BundleError::RevealsOutOfOrder)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn validate_rejects_duplicate_reveals() {
|
|||
|
|
let (pk, sk) = keypair();
|
|||
|
|
let (node_id, rec) = make_node(*pk.as_bytes(), 1);
|
|||
|
|
let mut nt = NodeTable::new();
|
|||
|
|
nt.insert(rec);
|
|||
|
|
let endpoint = [0; 32];
|
|||
|
|
let bc = build_signed_bc(
|
|||
|
|
&sk,
|
|||
|
|
node_id,
|
|||
|
|
endpoint,
|
|||
|
|
1,
|
|||
|
|
vec![],
|
|||
|
|
vec![[0x05; 32], [0x05; 32]],
|
|||
|
|
);
|
|||
|
|
assert_eq!(
|
|||
|
|
validate_bundle(&bc, &nt, &endpoint),
|
|||
|
|
Err(BundleError::RevealsOutOfOrder)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn validate_rejects_bad_signature() {
|
|||
|
|
let (pk, sk) = keypair();
|
|||
|
|
let (node_id, rec) = make_node(*pk.as_bytes(), 1);
|
|||
|
|
let mut nt = NodeTable::new();
|
|||
|
|
nt.insert(rec);
|
|||
|
|
let endpoint = [0; 32];
|
|||
|
|
let mut bc = build_signed_bc(&sk, node_id, endpoint, 1, vec![], vec![]);
|
|||
|
|
// испортить подпись
|
|||
|
|
let mut sig_bytes = *bc.signature.as_bytes();
|
|||
|
|
sig_bytes[0] ^= 0xFF;
|
|||
|
|
sig_bytes[100] ^= 0xAA;
|
|||
|
|
bc.signature = Signature::from_array(sig_bytes);
|
|||
|
|
assert_eq!(
|
|||
|
|
validate_bundle(&bc, &nt, &endpoint),
|
|||
|
|
Err(BundleError::InvalidSignature)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn validate_rejects_signature_from_different_key() {
|
|||
|
|
let (pk, _sk) = keypair();
|
|||
|
|
let (_other_pk, other_sk) = keypair();
|
|||
|
|
let (node_id, rec) = make_node(*pk.as_bytes(), 1);
|
|||
|
|
let mut nt = NodeTable::new();
|
|||
|
|
nt.insert(rec);
|
|||
|
|
let endpoint = [0; 32];
|
|||
|
|
// подписано other_sk, но NodeTable имеет pk
|
|||
|
|
let bc = build_signed_bc(&other_sk, node_id, endpoint, 1, vec![], vec![]);
|
|||
|
|
assert_eq!(
|
|||
|
|
validate_bundle(&bc, &nt, &endpoint),
|
|||
|
|
Err(BundleError::InvalidSignature)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn encode_determinism() {
|
|||
|
|
let bc = BundledConfirmation {
|
|||
|
|
node_id: [0x01; 32],
|
|||
|
|
endpoint: [0x02; 32],
|
|||
|
|
window_index: 42,
|
|||
|
|
op_hashes: vec![[0x03; 32]],
|
|||
|
|
reveal_hashes: vec![[0x04; 32]],
|
|||
|
|
signature: Signature::from_array([0x05; SIGNATURE_SIZE]),
|
|||
|
|
};
|
|||
|
|
let mut a = Vec::new();
|
|||
|
|
bc.encode(&mut a);
|
|||
|
|
let mut b = Vec::new();
|
|||
|
|
bc.encode(&mut b);
|
|||
|
|
assert_eq!(a, b);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn encoded_counts_are_le() {
|
|||
|
|
let bc = BundledConfirmation {
|
|||
|
|
node_id: [0; 32],
|
|||
|
|
endpoint: [0; 32],
|
|||
|
|
window_index: 0,
|
|||
|
|
op_hashes: vec![[0; 32]; 0x1234],
|
|||
|
|
reveal_hashes: vec![[0; 32]; 0x0056],
|
|||
|
|
signature: Signature::from_array([0; SIGNATURE_SIZE]),
|
|||
|
|
};
|
|||
|
|
let mut buf = Vec::new();
|
|||
|
|
bc.encode(&mut buf);
|
|||
|
|
// spec v33.1.5+: window_index 8B → op_count at offset 72..74
|
|||
|
|
assert_eq!(&buf[72..74], &[0x34, 0x12]);
|
|||
|
|
// reveal_count at offset 74 + 0x1234*32 = 74 + 149504 = 149578
|
|||
|
|
let reveal_count_offset = 74 + 0x1234 * 32;
|
|||
|
|
assert_eq!(
|
|||
|
|
&buf[reveal_count_offset..reveal_count_offset + 2],
|
|||
|
|
&[0x56, 0x00]
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn encoded_window_index_is_le() {
|
|||
|
|
let bc = BundledConfirmation {
|
|||
|
|
node_id: [0; 32],
|
|||
|
|
endpoint: [0; 32],
|
|||
|
|
window_index: 0xDEADBEEFCAFEBABE,
|
|||
|
|
op_hashes: vec![],
|
|||
|
|
reveal_hashes: vec![],
|
|||
|
|
signature: Signature::from_array([0; SIGNATURE_SIZE]),
|
|||
|
|
};
|
|||
|
|
let mut buf = Vec::new();
|
|||
|
|
bc.encode(&mut buf);
|
|||
|
|
// spec v33.1.5+: window_index u64 LE 8B at offset 64..72
|
|||
|
|
assert_eq!(
|
|||
|
|
&buf[64..72],
|
|||
|
|
&[0xBE, 0xBA, 0xFE, 0xCA, 0xEF, 0xBE, 0xAD, 0xDE]
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn bundle_hash_is_32_bytes() {
|
|||
|
|
let bc = BundledConfirmation {
|
|||
|
|
node_id: [0; 32],
|
|||
|
|
endpoint: [0; 32],
|
|||
|
|
window_index: 0,
|
|||
|
|
op_hashes: vec![],
|
|||
|
|
reveal_hashes: vec![],
|
|||
|
|
signature: Signature::from_array([0; SIGNATURE_SIZE]),
|
|||
|
|
};
|
|||
|
|
let h = bundle_hash(&bc);
|
|||
|
|
assert_eq!(h.len(), 32);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn bundle_fixed_overhead_value() {
|
|||
|
|
// spec v33.1.5+: 32 (node_id) + 32 (endpoint) + 8 (window_index u64) + 2 (op_count) + 2 (reveal_count)
|
|||
|
|
// + 3309 (signature ML-DSA-65) = 3385
|
|||
|
|
assert_eq!(BUNDLE_FIXED_OVERHEAD, 3385);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn secret_key_size_constant_available() {
|
|||
|
|
// Sanity: SECRET_KEY_SIZE импортирован и совпадает (ML-DSA-65 expanded = 4032)
|
|||
|
|
assert_eq!(SECRET_KEY_SIZE, 4032);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// -------- VdfReveal (Phase B) --------
|
|||
|
|
|
|||
|
|
fn build_signed_reveal(
|
|||
|
|
sk: &mt_crypto::SecretKey,
|
|||
|
|
node_id: NodeId,
|
|||
|
|
window_index: u64,
|
|||
|
|
endpoint: Hash32,
|
|||
|
|
) -> VdfReveal {
|
|||
|
|
let mut r = VdfReveal {
|
|||
|
|
node_id,
|
|||
|
|
window_index,
|
|||
|
|
endpoint,
|
|||
|
|
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
|
|||
|
|
};
|
|||
|
|
let mut scope = Vec::new();
|
|||
|
|
r.encode_signed_scope(&mut scope);
|
|||
|
|
r.signature = sign(sk, &scope).expect("sign Reveal scope");
|
|||
|
|
r
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn reveal_encode_matches_spec_layout() {
|
|||
|
|
let r = VdfReveal {
|
|||
|
|
node_id: [0xAA; 32],
|
|||
|
|
window_index: 0x0102030405060708,
|
|||
|
|
endpoint: [0xBB; 32],
|
|||
|
|
signature: Signature::from_array([0x44; SIGNATURE_SIZE]),
|
|||
|
|
};
|
|||
|
|
let mut buf = Vec::new();
|
|||
|
|
r.encode(&mut buf);
|
|||
|
|
assert_eq!(buf.len(), REVEAL_SIZE);
|
|||
|
|
// spec v33.1.5+: 32 (node_id) + 8 (window u64 LE) + 32 (endpoint) + 3309 (signature ML-DSA-65) = 3381
|
|||
|
|
assert_eq!(REVEAL_SIZE, 3381);
|
|||
|
|
|
|||
|
|
// node_id: 0..32
|
|||
|
|
assert_eq!(&buf[0..32], &[0xAA; 32]);
|
|||
|
|
// window_index u64 LE: 32..40 (spec v33.1.5+ — было u32 4B до v33.1.5)
|
|||
|
|
assert_eq!(
|
|||
|
|
&buf[32..40],
|
|||
|
|
&[0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01]
|
|||
|
|
);
|
|||
|
|
// endpoint: 40..72
|
|||
|
|
assert_eq!(&buf[40..72], &[0xBB; 32]);
|
|||
|
|
// signature: 72..72+SIGNATURE_SIZE
|
|||
|
|
assert_eq!(&buf[72..72 + SIGNATURE_SIZE], &[0x44; SIGNATURE_SIZE]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn reveal_signed_scope_excludes_signature() {
|
|||
|
|
let r = VdfReveal {
|
|||
|
|
node_id: [0x11; 32],
|
|||
|
|
window_index: 42,
|
|||
|
|
endpoint: [0x22; 32],
|
|||
|
|
signature: Signature::from_array([0xCC; SIGNATURE_SIZE]),
|
|||
|
|
};
|
|||
|
|
let mut scope = Vec::new();
|
|||
|
|
r.encode_signed_scope(&mut scope);
|
|||
|
|
let mut full = Vec::new();
|
|||
|
|
r.encode(&mut full);
|
|||
|
|
assert_eq!(full.len(), scope.len() + SIGNATURE_SIZE);
|
|||
|
|
assert_eq!(&full[..scope.len()], scope.as_slice());
|
|||
|
|
assert_eq!(scope.len(), 32 + 8 + 32);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn reveal_hash_domain_mt_vdf_reveal() {
|
|||
|
|
let r = VdfReveal {
|
|||
|
|
node_id: [0x01; 32],
|
|||
|
|
window_index: 7,
|
|||
|
|
endpoint: [0x02; 32],
|
|||
|
|
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
|
|||
|
|
};
|
|||
|
|
let mut scope = Vec::new();
|
|||
|
|
r.encode_signed_scope(&mut scope);
|
|||
|
|
let expected = hash(b"mt-vdf-reveal", &[&scope]);
|
|||
|
|
assert_eq!(reveal_hash(&r), expected);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn reveal_hash_stable_across_resign() {
|
|||
|
|
let mut r = VdfReveal {
|
|||
|
|
node_id: [0x01; 32],
|
|||
|
|
window_index: 7,
|
|||
|
|
endpoint: [0x02; 32],
|
|||
|
|
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
|
|||
|
|
};
|
|||
|
|
let h1 = reveal_hash(&r);
|
|||
|
|
r.signature = Signature::from_array([0xFF; SIGNATURE_SIZE]);
|
|||
|
|
let h2 = reveal_hash(&r);
|
|||
|
|
assert_eq!(h1, h2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn endpoint_formula_matches_spec() {
|
|||
|
|
// spec v33.1.5+: endpoint = SHA-256("mt-lottery" || T_r || cba(W-2) || node_id || window_index(8B LE))
|
|||
|
|
let t_r: Hash32 = [0x10; 32];
|
|||
|
|
let cba: Hash32 = [0x20; 32];
|
|||
|
|
let node_id: NodeId = [0x30; 32];
|
|||
|
|
let window_index: u64 = 0xDEADBEEFCAFEBABE;
|
|||
|
|
let got = compute_endpoint(&t_r, &cba, &node_id, window_index);
|
|||
|
|
|
|||
|
|
let mut w_le = Vec::new();
|
|||
|
|
mt_codec::write_u64(&mut w_le, window_index);
|
|||
|
|
let expected = hash(b"mt-lottery", &[&t_r, &cba, &node_id, &w_le]);
|
|||
|
|
assert_eq!(got, expected);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn endpoint_changes_with_each_input() {
|
|||
|
|
let t_r: Hash32 = [0x10; 32];
|
|||
|
|
let cba: Hash32 = [0x20; 32];
|
|||
|
|
let node_id: NodeId = [0x30; 32];
|
|||
|
|
let w: u64 = 5;
|
|||
|
|
let base = compute_endpoint(&t_r, &cba, &node_id, w);
|
|||
|
|
let alt_t_r = [0x11; 32];
|
|||
|
|
let alt_cba = [0x21; 32];
|
|||
|
|
let alt_node = [0x31; 32];
|
|||
|
|
assert_ne!(compute_endpoint(&alt_t_r, &cba, &node_id, w), base);
|
|||
|
|
assert_ne!(compute_endpoint(&t_r, &alt_cba, &node_id, w), base);
|
|||
|
|
assert_ne!(compute_endpoint(&t_r, &cba, &alt_node, w), base);
|
|||
|
|
assert_ne!(compute_endpoint(&t_r, &cba, &node_id, w + 1), base);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn validate_reveal_accepts_valid() {
|
|||
|
|
let (pk, sk) = keypair();
|
|||
|
|
let (node_id, rec) = make_node(*pk.as_bytes(), 1);
|
|||
|
|
let mut nt = NodeTable::new();
|
|||
|
|
nt.insert(rec);
|
|||
|
|
let t_r = [0x55; 32];
|
|||
|
|
let cba = [0x66; 32];
|
|||
|
|
let w: u64 = 100;
|
|||
|
|
let endpoint = compute_endpoint(&t_r, &cba, &node_id, w);
|
|||
|
|
let r = build_signed_reveal(&sk, node_id, w, endpoint);
|
|||
|
|
assert_eq!(validate_reveal(&r, &nt, &t_r, &cba, w), Ok(()));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn validate_reveal_rejects_unknown_node() {
|
|||
|
|
let (pk, sk) = keypair();
|
|||
|
|
let (node_id, _rec) = make_node(*pk.as_bytes(), 1);
|
|||
|
|
let nt = NodeTable::new();
|
|||
|
|
let t_r = [0; 32];
|
|||
|
|
let cba = [0; 32];
|
|||
|
|
let w = 1;
|
|||
|
|
let endpoint = compute_endpoint(&t_r, &cba, &node_id, w);
|
|||
|
|
let r = build_signed_reveal(&sk, node_id, w, endpoint);
|
|||
|
|
assert_eq!(
|
|||
|
|
validate_reveal(&r, &nt, &t_r, &cba, w),
|
|||
|
|
Err(RevealError::UnknownNode)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn validate_reveal_rejects_unsupported_suite() {
|
|||
|
|
let (pk, sk) = keypair();
|
|||
|
|
let (node_id, mut rec) = make_node(*pk.as_bytes(), 1);
|
|||
|
|
rec.suite_id = 0xFFFF;
|
|||
|
|
let mut nt = NodeTable::new();
|
|||
|
|
nt.insert(rec);
|
|||
|
|
let t_r = [0; 32];
|
|||
|
|
let cba = [0; 32];
|
|||
|
|
let w = 1;
|
|||
|
|
let endpoint = compute_endpoint(&t_r, &cba, &node_id, w);
|
|||
|
|
let r = build_signed_reveal(&sk, node_id, w, endpoint);
|
|||
|
|
assert_eq!(
|
|||
|
|
validate_reveal(&r, &nt, &t_r, &cba, w),
|
|||
|
|
Err(RevealError::UnsupportedSuite)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn validate_reveal_rejects_wrong_window() {
|
|||
|
|
let (pk, sk) = keypair();
|
|||
|
|
let (node_id, rec) = make_node(*pk.as_bytes(), 1);
|
|||
|
|
let mut nt = NodeTable::new();
|
|||
|
|
nt.insert(rec);
|
|||
|
|
let t_r = [0; 32];
|
|||
|
|
let cba = [0; 32];
|
|||
|
|
let w: u64 = 10;
|
|||
|
|
let endpoint = compute_endpoint(&t_r, &cba, &node_id, w);
|
|||
|
|
let r = build_signed_reveal(&sk, node_id, w, endpoint);
|
|||
|
|
// current_window = 11, reveal.window_index = 10
|
|||
|
|
assert_eq!(
|
|||
|
|
validate_reveal(&r, &nt, &t_r, &cba, 11),
|
|||
|
|
Err(RevealError::WrongWindow)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn validate_reveal_rejects_wrong_endpoint() {
|
|||
|
|
let (pk, sk) = keypair();
|
|||
|
|
let (node_id, rec) = make_node(*pk.as_bytes(), 1);
|
|||
|
|
let mut nt = NodeTable::new();
|
|||
|
|
nt.insert(rec);
|
|||
|
|
let t_r = [0x11; 32];
|
|||
|
|
let cba = [0x22; 32];
|
|||
|
|
let w: u64 = 5;
|
|||
|
|
// заявленный endpoint — произвольный, не равен compute_endpoint
|
|||
|
|
let bogus_endpoint = [0xEE; 32];
|
|||
|
|
let r = build_signed_reveal(&sk, node_id, w, bogus_endpoint);
|
|||
|
|
assert_eq!(
|
|||
|
|
validate_reveal(&r, &nt, &t_r, &cba, w),
|
|||
|
|
Err(RevealError::WrongEndpoint)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn validate_reveal_rejects_bad_signature() {
|
|||
|
|
let (pk, sk) = keypair();
|
|||
|
|
let (node_id, rec) = make_node(*pk.as_bytes(), 1);
|
|||
|
|
let mut nt = NodeTable::new();
|
|||
|
|
nt.insert(rec);
|
|||
|
|
let t_r = [0x11; 32];
|
|||
|
|
let cba = [0x22; 32];
|
|||
|
|
let w: u64 = 5;
|
|||
|
|
let endpoint = compute_endpoint(&t_r, &cba, &node_id, w);
|
|||
|
|
let mut r = build_signed_reveal(&sk, node_id, w, endpoint);
|
|||
|
|
let mut sig_bytes = *r.signature.as_bytes();
|
|||
|
|
sig_bytes[0] ^= 0xFF;
|
|||
|
|
sig_bytes[200] ^= 0xAA;
|
|||
|
|
r.signature = Signature::from_array(sig_bytes);
|
|||
|
|
assert_eq!(
|
|||
|
|
validate_reveal(&r, &nt, &t_r, &cba, w),
|
|||
|
|
Err(RevealError::InvalidSignature)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn validate_reveal_rejects_signature_from_different_key() {
|
|||
|
|
let (pk, _sk) = keypair();
|
|||
|
|
let (_other_pk, other_sk) = keypair();
|
|||
|
|
let (node_id, rec) = make_node(*pk.as_bytes(), 1);
|
|||
|
|
let mut nt = NodeTable::new();
|
|||
|
|
nt.insert(rec);
|
|||
|
|
let t_r = [0; 32];
|
|||
|
|
let cba = [0; 32];
|
|||
|
|
let w: u64 = 1;
|
|||
|
|
let endpoint = compute_endpoint(&t_r, &cba, &node_id, w);
|
|||
|
|
// подпись от other_sk, но NodeTable указывает на pk
|
|||
|
|
let r = build_signed_reveal(&other_sk, node_id, w, endpoint);
|
|||
|
|
assert_eq!(
|
|||
|
|
validate_reveal(&r, &nt, &t_r, &cba, w),
|
|||
|
|
Err(RevealError::InvalidSignature)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn reveal_encode_determinism() {
|
|||
|
|
let r = VdfReveal {
|
|||
|
|
node_id: [0x42; 32],
|
|||
|
|
window_index: 99,
|
|||
|
|
endpoint: [0x77; 32],
|
|||
|
|
signature: Signature::from_array([0xAA; SIGNATURE_SIZE]),
|
|||
|
|
};
|
|||
|
|
let mut a = Vec::new();
|
|||
|
|
r.encode(&mut a);
|
|||
|
|
let mut b = Vec::new();
|
|||
|
|
r.encode(&mut b);
|
|||
|
|
assert_eq!(a, b);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn reveal_size_constant_value() {
|
|||
|
|
// spec v33.1.5+: window_index u64 8B (было u32 4B до v33.1.5)
|
|||
|
|
assert_eq!(REVEAL_SIZE, 32 + 8 + 32 + SIGNATURE_SIZE);
|
|||
|
|
assert_eq!(REVEAL_SIZE, 3381);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============ Phase C: Node lottery ============
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn seniority_below_13_is_zero() {
|
|||
|
|
// chain_length < 13 → seniority_term = 0 (integer div truncation)
|
|||
|
|
for cl in [0u64, 1, 5, 12] {
|
|||
|
|
assert_eq!(seniority_term(cl, 1000), 0);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn seniority_at_13_is_one() {
|
|||
|
|
assert_eq!(seniority_term(13, 1000), 1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn seniority_capped_by_snapshot() {
|
|||
|
|
// chain_length / 13 может превысить snapshot → cap на snapshot
|
|||
|
|
let cl = 130_000u64;
|
|||
|
|
let snap = 100u64;
|
|||
|
|
// 130_000 / 13 = 10_000, min(10_000, 100) = 100
|
|||
|
|
assert_eq!(seniority_term(cl, snap), 100);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn lottery_weight_sum_of_components() {
|
|||
|
|
// chain_length = 26 → chain_length / 13 = 2
|
|||
|
|
// snapshot = 10 → seniority_term = min(2, 10) = 2
|
|||
|
|
// lottery_weight = 10 + 2 = 12
|
|||
|
|
assert_eq!(lottery_weight(26, 10), 12);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn lottery_weight_ds2_floor_at_snapshot_one() {
|
|||
|
|
// DS-2: snapshot ≥ 1 ⇒ lottery_weight ≥ 1
|
|||
|
|
// chain_length = 0 → seniority = 0 → weight = snapshot
|
|||
|
|
assert_eq!(lottery_weight(0, 1), 1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn lottery_weight_new_node_13_windows() {
|
|||
|
|
// Первые 13 окон: chain_length < 13, seniority = 0, weight = snapshot
|
|||
|
|
// Per spec: "первые 13 окон после регистрации lottery_weight = snapshot"
|
|||
|
|
assert_eq!(lottery_weight(1, 1), 1);
|
|||
|
|
assert_eq!(lottery_weight(7, 7), 7);
|
|||
|
|
assert_eq!(lottery_weight(12, 12), 12);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn lottery_weight_max_2x_snapshot() {
|
|||
|
|
// Spec: "максимальное преимущество старожила ≈ 2x относительно новичка"
|
|||
|
|
// seniority capped by snapshot ⇒ lottery_weight ≤ 2 × snapshot
|
|||
|
|
let snap = 120_960u64; // 6τ₂ при τ₂ = 20 160
|
|||
|
|
let max_cl = u64::MAX;
|
|||
|
|
let w = lottery_weight(max_cl, snap);
|
|||
|
|
assert!(w <= 2 * snap);
|
|||
|
|
assert_eq!(w, 2 * snap); // saturated cap
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn log2_q64_zero_endpoint_saturates() {
|
|||
|
|
assert_eq!(log2_q64(&[0u8; 32]), u128::MAX);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn log2_q64_one_endpoint_max_log() {
|
|||
|
|
// endpoint = 1 (only LSB set) → log2(2^256/1) = 256 exactly.
|
|||
|
|
// poly3 approximation at y=0: frac = 2·B0 ≈ 2^49 (minimax equioscillating
|
|||
|
|
// error at endpoints, not zero). Binding output: (leading+1)·2^64 - 2·B0.
|
|||
|
|
let mut e = [0u8; 32];
|
|||
|
|
e[31] = 1;
|
|||
|
|
let log2 = log2_q64(&e);
|
|||
|
|
let two_b0 = 2u128 * 0x0014_E086_EC98_2D63u128; // 2·B0 = frac at y=0
|
|||
|
|
assert_eq!(log2, (256u128 << 64) - two_b0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn log2_q64_full_bits_minimal() {
|
|||
|
|
// endpoint = 0xFF..FF (all bits set), msb at bit 255
|
|||
|
|
// leading_zeros = 0, y ≈ 1 - ε
|
|||
|
|
// Real log2(2^256 / (2^256 - 1)) ≈ 0 (very small positive)
|
|||
|
|
// Линейная аппроксимация: frac = 2^64 - x_q64 где x_q64 ≈ 2^64 - 1
|
|||
|
|
// ⇒ frac ≈ 1 Q64 (near zero в real units)
|
|||
|
|
let e = [0xFFu8; 32];
|
|||
|
|
let log2 = log2_q64(&e);
|
|||
|
|
assert_eq!(log2 >> 64, 0);
|
|||
|
|
// frac в младшей области — near-zero в real units
|
|||
|
|
let frac = log2 & ((1u128 << 64) - 1);
|
|||
|
|
assert!(frac < (1u128 << 4)); // менее 2^-60 real — очень близко к нулю
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn log2_q64_monotonic_descending_in_endpoint() {
|
|||
|
|
// endpoint_a < endpoint_b ⇒ log2_q64(a) >= log2_q64(b)
|
|||
|
|
let mut prev = u128::MAX;
|
|||
|
|
for i in [1u8, 2, 5, 10, 20, 50, 100, 200] {
|
|||
|
|
let mut e = [0u8; 32];
|
|||
|
|
e[0] = i; // top byte only
|
|||
|
|
let v = log2_q64(&e);
|
|||
|
|
assert!(v <= prev, "log2 not monotonic decreasing: i={i}");
|
|||
|
|
prev = v;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn log2_q64_determinism() {
|
|||
|
|
let e = [0x55u8; 32];
|
|||
|
|
let a = log2_q64(&e);
|
|||
|
|
let b = log2_q64(&e);
|
|||
|
|
assert_eq!(a, b);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn ln_q64_zero_endpoint_saturates() {
|
|||
|
|
assert_eq!(ln_q64(&[0u8; 32]), u128::MAX);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn ln_q64_monotonic_descending() {
|
|||
|
|
// Same property as log2_q64: smaller endpoint → larger ln
|
|||
|
|
let mut prev = u128::MAX;
|
|||
|
|
for i in [1u8, 2, 5, 10, 20, 50, 100, 200] {
|
|||
|
|
let mut e = [0u8; 32];
|
|||
|
|
e[0] = i;
|
|||
|
|
let v = ln_q64(&e);
|
|||
|
|
assert!(v <= prev);
|
|||
|
|
prev = v;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn ln_q64_determinism() {
|
|||
|
|
let e = [0xABu8; 32];
|
|||
|
|
assert_eq!(ln_q64(&e), ln_q64(&e));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn ln_q64_equals_log2_times_ln2() {
|
|||
|
|
// ln(x) = log2(x) × ln(2) ; в Q64: ln_q64 = (log2_q64 × LN2_Q64) >> 64
|
|||
|
|
let e = [0x77u8; 32];
|
|||
|
|
let log2 = log2_q64(&e);
|
|||
|
|
let ln_direct = ln_q64(&e);
|
|||
|
|
|
|||
|
|
// Reference computation
|
|||
|
|
let log2_high = (log2 >> 64) as u64;
|
|||
|
|
let log2_low = log2 as u64;
|
|||
|
|
let term_high = (log2_high as u128) * LN2_Q64;
|
|||
|
|
let term_low = ((log2_low as u128) * LN2_Q64) >> 64;
|
|||
|
|
let ln_expected = term_high.saturating_add(term_low);
|
|||
|
|
|
|||
|
|
assert_eq!(ln_direct, ln_expected);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============ Binding test vectors (spec v29.12.0, Integer log algorithm) ============
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn ln_q64_binding_tv1_boundary_low() {
|
|||
|
|
// TV1 — endpoint = 0x00..01 (smallest non-zero) → largest ln
|
|||
|
|
let e = {
|
|||
|
|
let mut e = [0u8; 32];
|
|||
|
|
e[31] = 1;
|
|||
|
|
e
|
|||
|
|
};
|
|||
|
|
assert_eq!(ln_q64(&e), 0x00000000000000b171fb06bb5b60c961u128);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn ln_q64_binding_tv2_msb_only() {
|
|||
|
|
// TV2 — endpoint = 2^255 → log2 = 1 → ticket ≈ LN2_Q64
|
|||
|
|
let mut e = [0u8; 32];
|
|||
|
|
e[0] = 0x80;
|
|||
|
|
assert_eq!(ln_q64(&e), 0x0000000000000000b15526e15db6980cu128);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn ln_q64_binding_tv3_typical() {
|
|||
|
|
// TV3 — typical dense pattern
|
|||
|
|
let e = [
|
|||
|
|
0xbb, 0xaa, 0x99, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, 0xff, 0xee, 0xdd,
|
|||
|
|
0xcc, 0xbb, 0xaa, 0x99, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, 0xff, 0xee,
|
|||
|
|
0xdd, 0xcc, 0xbb, 0xaa,
|
|||
|
|
];
|
|||
|
|
assert_eq!(ln_q64(&e), 0x00000000000000004f60bd6fe6504646u128);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn ln_q64_binding_tv4_near_max() {
|
|||
|
|
// TV4 — endpoint = 2^256-1 → log2(2^256/e) ≈ 0 → ticket = 0
|
|||
|
|
let e = [0xFFu8; 32];
|
|||
|
|
assert_eq!(ln_q64(&e), 0u128);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn ln_q64_binding_tv5_peak_error_region() {
|
|||
|
|
// TV5 — peak-error region (y ≈ 0.84, attacker-favorable peak of equioscillation)
|
|||
|
|
let e = [
|
|||
|
|
0xeb, 0x85, 0x1e, 0xb8, 0x51, 0xeb, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|||
|
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|||
|
|
0x00, 0x00, 0x00, 0x00,
|
|||
|
|
];
|
|||
|
|
assert_eq!(ln_q64(&e), 0x000000000000000015756c980b547a82u128);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Binding test vectors: weighted_ticket_node (spec v29.12.0, Лотерея узлов)
|
|||
|
|
// Все используют ln_q64 = 0x4f60bd6fe6504646 от TV3 endpoint.
|
|||
|
|
|
|||
|
|
fn tv3_endpoint() -> [u8; 32] {
|
|||
|
|
[
|
|||
|
|
0xbb, 0xaa, 0x99, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, 0xff, 0xee, 0xdd,
|
|||
|
|
0xcc, 0xbb, 0xaa, 0x99, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, 0xff, 0xee,
|
|||
|
|
0xdd, 0xcc, 0xbb, 0xaa,
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn weighted_ticket_node_binding_n1_typical() {
|
|||
|
|
// chain=1000, snap=500, N=13: seniority = min(1000/13=76, 500) = 76
|
|||
|
|
// weight = 500 + 76 = 576
|
|||
|
|
let t = weighted_ticket_node(&tv3_endpoint(), 1000, 500);
|
|||
|
|
assert_eq!(t, 0x000000000000000000234770A382CE58u128);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn weighted_ticket_node_binding_n2_boundary() {
|
|||
|
|
// DS-2 floor: chain_length=1, snapshot=1 → weight=1
|
|||
|
|
let t = weighted_ticket_node(&tv3_endpoint(), 1, 1);
|
|||
|
|
assert_eq!(t, 0x00000000000000004F60BD6FE6504646u128);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn weighted_ticket_node_binding_n3_seniority_cap() {
|
|||
|
|
// chain_length/13 = 76923 но capped by snapshot=10 → seniority=10, weight=20
|
|||
|
|
let t = weighted_ticket_node(&tv3_endpoint(), 1_000_000, 10);
|
|||
|
|
assert_eq!(t, 0x000000000000000003F80978CB840383u128);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn weighted_ticket_node_binding_n4_max_boundary() {
|
|||
|
|
// chain_length=u64::MAX, snapshot=120960 (6τ₂ актуальный snapshot_max)
|
|||
|
|
// → seniority=120960 (capped), weight=241920
|
|||
|
|
let t = weighted_ticket_node(&tv3_endpoint(), u64::MAX, 120_960);
|
|||
|
|
assert_eq!(t, 0x000000000000000000001580E0B1AED0u128);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn weighted_ticket_node_binding_n5_seniority_threshold() {
|
|||
|
|
// chain_length=13 exactly: seniority_term = 13/13 = 1, weight = 1+1 = 2
|
|||
|
|
// Это первое целое значение chain_length дающее seniority_term ≥ 1.
|
|||
|
|
let t = weighted_ticket_node(&tv3_endpoint(), 13, 1);
|
|||
|
|
assert_eq!(t, 0x000000000000000027B05EB7F3282323u128);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// spec Sovereignty Ladder: binding vectors A1-A4 (weighted_ticket_account)
|
|||
|
|
// удалены. Account lottery не существует в текущей схеме.
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn weighted_ticket_node_determinism() {
|
|||
|
|
let e = [0x42u8; 32];
|
|||
|
|
assert_eq!(
|
|||
|
|
weighted_ticket_node(&e, 1000, 500),
|
|||
|
|
weighted_ticket_node(&e, 1000, 500)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn weighted_ticket_node_decreases_with_weight() {
|
|||
|
|
// Больший lottery_weight → меньший weighted_ticket (при том же endpoint).
|
|||
|
|
// Больше веса — больше шанс победить (меньше ticket в argmin).
|
|||
|
|
let e = [0x33u8; 32];
|
|||
|
|
let low_w = weighted_ticket_node(&e, 0, 100);
|
|||
|
|
let high_w = weighted_ticket_node(&e, 0, 10000);
|
|||
|
|
assert!(high_w < low_w);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn weighted_ticket_node_monotonic_in_endpoint() {
|
|||
|
|
// При одинаковом weight: меньший endpoint → больший ticket.
|
|||
|
|
let snap = 1000u64;
|
|||
|
|
let cl = 10_000u64;
|
|||
|
|
let mut prev = u128::MAX;
|
|||
|
|
for i in [1u8, 10, 50, 100, 200, 255] {
|
|||
|
|
let mut e = [0u8; 32];
|
|||
|
|
e[0] = i;
|
|||
|
|
let v = weighted_ticket_node(&e, cl, snap);
|
|||
|
|
assert!(v <= prev);
|
|||
|
|
prev = v;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn weighted_ticket_node_zero_weight_saturates() {
|
|||
|
|
// Нарушение DS-2: snapshot = 0 → weight = 0 → защитный u128::MAX
|
|||
|
|
let e = [0x55u8; 32];
|
|||
|
|
assert_eq!(weighted_ticket_node(&e, 0, 0), u128::MAX);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn weighted_ticket_node_integer_div_toward_zero() {
|
|||
|
|
// Явно проверить что integer div, не rounding
|
|||
|
|
let mut e = [0u8; 32];
|
|||
|
|
e[0] = 0x80; // endpoint = 2^255
|
|||
|
|
let w: u64 = 3;
|
|||
|
|
let ticket = ln_q64(&e);
|
|||
|
|
let expected = ticket / (w as u128); // integer div
|
|||
|
|
assert_eq!(weighted_ticket_node(&e, 0, w), expected);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn log2_q64_boundary_msb_at_127() {
|
|||
|
|
// msb at bit 127 (границу между hi/lo halves) — edge case мантиссы
|
|||
|
|
let mut e = [0u8; 32];
|
|||
|
|
e[16] = 0x80; // bit 127 set в low half
|
|||
|
|
let v = log2_q64(&e);
|
|||
|
|
// leading = 128, y = 0, total = 129·2^64 - 2·B0 (minimax poly3 error at y=0)
|
|||
|
|
let two_b0 = 2u128 * 0x0014_E086_EC98_2D63u128;
|
|||
|
|
assert_eq!(v, (129u128 << 64) - two_b0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn log2_q64_boundary_msb_at_128() {
|
|||
|
|
// msb at bit 128 — первый бит high half
|
|||
|
|
let mut e = [0u8; 32];
|
|||
|
|
e[15] = 0x01; // bit 128 set
|
|||
|
|
let v = log2_q64(&e);
|
|||
|
|
// leading = 127, y = 0, total = 128·2^64 - 2·B0 (minimax poly3 error at y=0)
|
|||
|
|
let two_b0 = 2u128 * 0x0014_E086_EC98_2D63u128;
|
|||
|
|
assert_eq!(v, (128u128 << 64) - two_b0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn ln2_q64_constant_value() {
|
|||
|
|
// LN2_Q64 ≈ ln(2) × 2^64
|
|||
|
|
// ln(2) ≈ 0.693147... × 2^64 ≈ 0xB17217F7D1CF79AB
|
|||
|
|
assert_eq!(LN2_Q64, 0xB172_17F7_D1CF_79AB);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============ Phase E: Winner determination ============
|
|||
|
|
|
|||
|
|
fn cand(ticket: u128, class: u8, id_byte: u8) -> Candidate {
|
|||
|
|
Candidate {
|
|||
|
|
ticket,
|
|||
|
|
class,
|
|||
|
|
id: [id_byte; 32],
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn winner_empty_candidates_returns_none() {
|
|||
|
|
assert_eq!(determine_winner(&[]), None);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn winner_single_candidate() {
|
|||
|
|
let c = cand(100, WINNER_CLASS_NODE, 0x11);
|
|||
|
|
let w = determine_winner(&[c]).unwrap();
|
|||
|
|
assert_eq!(w.class, WINNER_CLASS_NODE);
|
|||
|
|
assert_eq!(w.id, [0x11; 32]);
|
|||
|
|
assert_eq!(w.ticket, 100);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn winner_picks_minimum_ticket() {
|
|||
|
|
let a = cand(500, WINNER_CLASS_NODE, 0x11);
|
|||
|
|
let b = cand(100, WINNER_CLASS_NODE, 0x22);
|
|||
|
|
let c = cand(300, WINNER_CLASS_NODE, 0x33);
|
|||
|
|
let w = determine_winner(&[a, b, c]).unwrap();
|
|||
|
|
assert_eq!(w.id, [0x22; 32]);
|
|||
|
|
assert_eq!(w.ticket, 100);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn winner_node_account_mixed_min_wins() {
|
|||
|
|
// Account с меньшим ticket побеждает над node
|
|||
|
|
let node = cand(500, WINNER_CLASS_NODE, 0x11);
|
|||
|
|
let acc = cand(100, WINNER_CLASS_NODE, 0x22);
|
|||
|
|
let w = determine_winner(&[node, acc]).unwrap();
|
|||
|
|
assert_eq!(w.class, WINNER_CLASS_NODE);
|
|||
|
|
assert_eq!(w.ticket, 100);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn winner_tie_breaker_class_node_preferred() {
|
|||
|
|
// Same ticket: Node (class=1) < Account (class=2) → Node wins
|
|||
|
|
let node = cand(100, WINNER_CLASS_NODE, 0xFF);
|
|||
|
|
let acc = cand(100, WINNER_CLASS_NODE, 0x00);
|
|||
|
|
let w = determine_winner(&[acc, node]).unwrap();
|
|||
|
|
assert_eq!(w.class, WINNER_CLASS_NODE);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn winner_tie_breaker_id_lex_ascending() {
|
|||
|
|
// Same ticket, same class: id lex asc
|
|||
|
|
let a = cand(100, WINNER_CLASS_NODE, 0x22);
|
|||
|
|
let b = cand(100, WINNER_CLASS_NODE, 0x11);
|
|||
|
|
let w = determine_winner(&[a, b]).unwrap();
|
|||
|
|
assert_eq!(w.id, [0x11; 32]); // lex меньший
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn winner_deterministic_on_permutation() {
|
|||
|
|
let c1 = cand(500, WINNER_CLASS_NODE, 0x11);
|
|||
|
|
let c2 = cand(100, WINNER_CLASS_NODE, 0x22);
|
|||
|
|
let c3 = cand(300, WINNER_CLASS_NODE, 0x33);
|
|||
|
|
let w1 = determine_winner(&[c1, c2, c3]).unwrap();
|
|||
|
|
let w2 = determine_winner(&[c3, c2, c1]).unwrap();
|
|||
|
|
let w3 = determine_winner(&[c2, c3, c1]).unwrap();
|
|||
|
|
assert_eq!(w1, w2);
|
|||
|
|
assert_eq!(w2, w3);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn sorted_candidates_fallback_order() {
|
|||
|
|
let c1 = cand(500, WINNER_CLASS_NODE, 0x11);
|
|||
|
|
let c2 = cand(100, WINNER_CLASS_NODE, 0x22);
|
|||
|
|
let c3 = cand(300, WINNER_CLASS_NODE, 0x33);
|
|||
|
|
let sorted = sorted_candidates_for_fallback(&[c1, c2, c3]);
|
|||
|
|
assert_eq!(sorted[0].ticket, 100); // winner
|
|||
|
|
assert_eq!(sorted[1].ticket, 300); // fallback_1
|
|||
|
|
assert_eq!(sorted[2].ticket, 500); // fallback_2
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn sorted_candidates_empty() {
|
|||
|
|
let sorted = sorted_candidates_for_fallback(&[]);
|
|||
|
|
assert!(sorted.is_empty());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn sorted_candidates_stable_on_permutation() {
|
|||
|
|
let c1 = cand(500, WINNER_CLASS_NODE, 0x11);
|
|||
|
|
let c2 = cand(100, WINNER_CLASS_NODE, 0x22);
|
|||
|
|
let c3 = cand(300, WINNER_CLASS_NODE, 0x33);
|
|||
|
|
let s1 = sorted_candidates_for_fallback(&[c1, c2, c3]);
|
|||
|
|
let s2 = sorted_candidates_for_fallback(&[c3, c2, c1]);
|
|||
|
|
assert_eq!(s1, s2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn winner_with_u128_max_tickets() {
|
|||
|
|
// Защитный u128::MAX при DS-2/DS-3 violation
|
|||
|
|
let good = cand(1000, WINNER_CLASS_NODE, 0x11);
|
|||
|
|
let bad = cand(u128::MAX, WINNER_CLASS_NODE, 0x22);
|
|||
|
|
let w = determine_winner(&[good, bad]).unwrap();
|
|||
|
|
assert_eq!(w.ticket, 1000);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============ Phase F: Quorum ============
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn quorum_spec_test_vectors() {
|
|||
|
|
// Spec v29.8.0 binding test vectors (P3):
|
|||
|
|
assert_eq!(quorum(1), 1);
|
|||
|
|
assert_eq!(quorum(100), 67);
|
|||
|
|
assert_eq!(quorum(149), 100);
|
|||
|
|
assert_eq!(quorum(150), 101);
|
|||
|
|
assert_eq!(quorum(1000), 670);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn quorum_zero_active_is_zero() {
|
|||
|
|
// Edge case — no active nodes
|
|||
|
|
assert_eq!(quorum(0), 0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn quorum_monotonic_non_decreasing() {
|
|||
|
|
let mut prev = 0u64;
|
|||
|
|
for x in [0u64, 1, 10, 100, 1000, 10_000, 100_000] {
|
|||
|
|
let q = quorum(x);
|
|||
|
|
assert!(q >= prev);
|
|||
|
|
prev = q;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn quorum_large_no_overflow() {
|
|||
|
|
// 10^14 — max bound в спеке. 67 × 10^14 + 99 = 6.7e15 < 2^63.
|
|||
|
|
let big = 100_000_000_000_000u64;
|
|||
|
|
let q = quorum(big);
|
|||
|
|
// ~67% of big
|
|||
|
|
assert!(q > big / 2);
|
|||
|
|
assert!(q < big);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn is_cemented_at_exact_quorum() {
|
|||
|
|
// cemented_sum == quorum → cemented (>=)
|
|||
|
|
assert!(is_cemented(67, 100));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn is_cemented_below_quorum() {
|
|||
|
|
assert!(!is_cemented(66, 100));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn is_cemented_above_quorum() {
|
|||
|
|
assert!(is_cemented(100, 100));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn is_cemented_zero_active_zero_cemented() {
|
|||
|
|
// quorum(0) = 0, cemented_sum = 0 ≥ 0 → cemented
|
|||
|
|
// Это эдж кейс — в реальности active=0 halt liveness, не consensus
|
|||
|
|
assert!(is_cemented(0, 0));
|
|||
|
|
}
|
|||
|
|
}
|