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

2557 lines
92 KiB
Rust
Raw Normal View History

// spec, раздел "Account Chain (Block Lattice)"
use mt_codec::{domain, write_bytes, write_u128, write_u16, write_u8, CanonicalEncode};
use mt_crypto::{
hash, suite_id_from_u16, verify, Hash32, PublicKey, Signature, PUBLIC_KEY_SIZE, SIGNATURE_SIZE,
};
use mt_state::{
compute_state_root, derive_account_id, derive_node_id, AccountId, AccountRecord, AccountTable,
CandidatePool, NodeId, NodeRecord, NodeTable,
};
// spec v30.x: OpenAccount удалён; TransferActivation 0x0A создаёт AccountRecord
// через sponsor (existing sender платит, receiver получает). type byte 0x01 не выделен.
pub const TYPE_TRANSFER: u8 = 0x02;
pub const TYPE_CHANGE_KEY: u8 = 0x03;
pub const TYPE_ANCHOR: u8 = 0x04;
pub const TYPE_TRANSFER_ACTIVATION: u8 = 0x0A;
pub const TRANSFER_SIZE: usize = 1 + 32 + 32 + 32 + 16 + SIGNATURE_SIZE;
pub const CHANGE_KEY_SIZE: usize = 1 + 32 + 32 + 2 + PUBLIC_KEY_SIZE + SIGNATURE_SIZE;
pub const ANCHOR_SIZE: usize = 1 + 32 + 32 + 32 + 32 + SIGNATURE_SIZE;
// TransferActivation payload: sender 32 + receiver 32 + suite_id 2 + receiver_pubkey 1952 (ML-DSA-65) + amount 16
pub const TRANSFER_ACTIVATION_SIZE: usize =
1 + 32 + 32 + 32 + 2 + PUBLIC_KEY_SIZE + 16 + SIGNATURE_SIZE;
pub type AppId = [u8; 32];
pub type DataHash = [u8; 32];
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Transfer {
pub prev_hash: Hash32,
pub sender: AccountId,
pub link: AccountId,
pub amount: u128,
pub signature: Signature,
}
impl Transfer {
pub fn encode_signed_scope(&self, buf: &mut Vec<u8>) {
write_u8(buf, TYPE_TRANSFER);
write_bytes(buf, &self.prev_hash);
write_bytes(buf, &self.sender);
write_bytes(buf, &self.link);
write_u128(buf, self.amount);
}
}
impl CanonicalEncode for Transfer {
fn encode(&self, buf: &mut Vec<u8>) {
self.encode_signed_scope(buf);
write_bytes(buf, self.signature.as_bytes());
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ChangeKey {
pub prev_hash: Hash32,
pub sender: AccountId,
pub new_suite_id: u16,
pub new_pubkey: PublicKey,
pub signature: Signature,
}
impl ChangeKey {
pub fn encode_signed_scope(&self, buf: &mut Vec<u8>) {
write_u8(buf, TYPE_CHANGE_KEY);
write_bytes(buf, &self.prev_hash);
write_bytes(buf, &self.sender);
write_u16(buf, self.new_suite_id);
write_bytes(buf, self.new_pubkey.as_bytes());
}
}
impl CanonicalEncode for ChangeKey {
fn encode(&self, buf: &mut Vec<u8>) {
self.encode_signed_scope(buf);
write_bytes(buf, self.signature.as_bytes());
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Anchor {
pub prev_hash: Hash32,
pub sender: AccountId,
pub app_id: AppId,
pub data_hash: DataHash,
pub signature: Signature,
}
impl Anchor {
pub fn encode_signed_scope(&self, buf: &mut Vec<u8>) {
write_u8(buf, TYPE_ANCHOR);
write_bytes(buf, &self.prev_hash);
write_bytes(buf, &self.sender);
write_bytes(buf, &self.app_id);
write_bytes(buf, &self.data_hash);
}
}
impl CanonicalEncode for Anchor {
fn encode(&self, buf: &mut Vec<u8>) {
self.encode_signed_scope(buf);
write_bytes(buf, self.signature.as_bytes());
}
}
// spec: TransferActivation — sponsor-activation операция, создаёт AccountRecord для receiver.
// Payload: sender + receiver + suite_id + receiver_pubkey + amount.
// Binding: receiver == SHA-256("mt-account" || suite_id || receiver_pubkey).
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransferActivation {
pub prev_hash: Hash32,
pub sender: AccountId,
pub receiver: AccountId,
pub suite_id: u16,
pub receiver_pubkey: PublicKey,
pub amount: u128,
pub signature: Signature,
}
impl TransferActivation {
pub fn encode_signed_scope(&self, buf: &mut Vec<u8>) {
write_u8(buf, TYPE_TRANSFER_ACTIVATION);
write_bytes(buf, &self.prev_hash);
write_bytes(buf, &self.sender);
write_bytes(buf, &self.receiver);
write_u16(buf, self.suite_id);
write_bytes(buf, self.receiver_pubkey.as_bytes());
write_u128(buf, self.amount);
}
}
impl CanonicalEncode for TransferActivation {
fn encode(&self, buf: &mut Vec<u8>) {
self.encode_signed_scope(buf);
write_bytes(buf, self.signature.as_bytes());
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Operation {
Transfer(Transfer),
ChangeKey(ChangeKey),
Anchor(Anchor),
TransferActivation(TransferActivation),
}
impl Operation {
pub fn encode_signed_scope(&self, buf: &mut Vec<u8>) {
match self {
Self::Transfer(op) => op.encode_signed_scope(buf),
Self::ChangeKey(op) => op.encode_signed_scope(buf),
Self::Anchor(op) => op.encode_signed_scope(buf),
Self::TransferActivation(op) => op.encode_signed_scope(buf),
}
}
}
impl CanonicalEncode for Operation {
fn encode(&self, buf: &mut Vec<u8>) {
match self {
Self::Transfer(op) => op.encode(buf),
Self::ChangeKey(op) => op.encode(buf),
Self::Anchor(op) => op.encode(buf),
Self::TransferActivation(op) => op.encode(buf),
}
}
}
// spec: Правило R2 — identifier(op) = SHA-256("mt-op" || signed_scope(op))
// Стабилен при любой схеме подписи (signature исключена из hash); для
// ML-DSA-65 deterministic variant signature тоже воспроизводима, но R2
// не зависит от этого свойства.
pub fn op_hash(op: &Operation) -> Hash32 {
let mut buf = Vec::new();
op.encode_signed_scope(&mut buf);
hash(domain::OP, &[&buf])
}
// spec: "Account Chain (Block Lattice)" + "Верификация баланса" + таблица валидации
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum OpError {
InvalidPrevHash,
DuplicateAccount,
AccountNotFound,
ReceiverNotActive,
ReceiverAlreadyExists,
InvalidBinding,
InvalidSignature,
InsufficientBalance,
SelfTransfer,
ZeroAmount,
UnsupportedSuite,
ActivationCooldownNotElapsed,
}
fn verify_signed_scope(
scope: &[u8],
signature: &Signature,
pubkey_bytes: &[u8; PUBLIC_KEY_SIZE],
) -> bool {
let pk = PublicKey::from_array(*pubkey_bytes);
verify(&pk, scope, signature)
}
pub fn validate_transfer(op: &Transfer, state: &AccountTable) -> Result<(), OpError> {
let sender = state.get(&op.sender).ok_or(OpError::AccountNotFound)?;
if sender.frontier_hash != op.prev_hash {
return Err(OpError::InvalidPrevHash);
}
if op.sender == op.link {
return Err(OpError::SelfTransfer);
}
if op.amount == 0 {
return Err(OpError::ZeroAmount);
}
if sender.balance < op.amount {
return Err(OpError::InsufficientBalance);
}
// spec: receiver MUST exist in AccountTable; new accounts создаются только через TransferActivation
if !state.contains(&op.link) {
return Err(OpError::ReceiverNotActive);
}
let mut scope = Vec::new();
op.encode_signed_scope(&mut scope);
if !verify_signed_scope(&scope, &op.signature, &sender.current_pubkey) {
return Err(OpError::InvalidSignature);
}
Ok(())
}
// spec: TransferActivation invariants per v30.4.0+:
// (a) sender exists, prev_hash matches frontier
// (b) receiver NOT in AccountTable (создание новой записи)
// (c) receiver == SHA-256("mt-account" || suite_id || receiver_pubkey) binding
// (d) amount > 0, sender.balance >= amount
// (e) cooldown [I-15]: current_window >= sender.last_activation_window + τ₂
// (sender.last_activation_window == 0 — никогда не активировал, без проверки)
// (f) signature valid для sender.current_pubkey
// current_window и tau2_windows — consensus-level types (u64), как в
// apply_proposal input. State поля sender.last_activation_window: u32
// (encoded size optimization до 4.29 млрд окон ~8000 лет). Cast u32→u64
// при сравнении делается inside функции — caller не обязан pre-cast.
pub fn validate_transfer_activation(
op: &TransferActivation,
state: &AccountTable,
current_window: u64,
tau2_windows: u64,
) -> Result<(), OpError> {
let sender = state.get(&op.sender).ok_or(OpError::AccountNotFound)?;
if sender.frontier_hash != op.prev_hash {
return Err(OpError::InvalidPrevHash);
}
if state.contains(&op.receiver) {
return Err(OpError::ReceiverAlreadyExists);
}
if suite_id_from_u16(op.suite_id).is_none() {
return Err(OpError::UnsupportedSuite);
}
let derived = derive_account_id(op.suite_id, op.receiver_pubkey.as_bytes());
if derived != op.receiver {
return Err(OpError::InvalidBinding);
}
if op.sender == op.receiver {
return Err(OpError::SelfTransfer);
}
if op.amount == 0 {
return Err(OpError::ZeroAmount);
}
if sender.balance < op.amount {
return Err(OpError::InsufficientBalance);
}
// spec [I-15]: cooldown 1 TransferActivation per sender per τ₂.
// Cast u32→u64 для consensus-level сравнения (state field — u32, ctx — u64).
if sender.last_activation_window != 0
&& current_window < (sender.last_activation_window as u64).saturating_add(tau2_windows)
{
return Err(OpError::ActivationCooldownNotElapsed);
}
let mut scope = Vec::new();
op.encode_signed_scope(&mut scope);
if !verify_signed_scope(&scope, &op.signature, &sender.current_pubkey) {
return Err(OpError::InvalidSignature);
}
Ok(())
}
pub fn validate_change_key(op: &ChangeKey, state: &AccountTable) -> Result<(), OpError> {
let sender = state.get(&op.sender).ok_or(OpError::AccountNotFound)?;
if sender.frontier_hash != op.prev_hash {
return Err(OpError::InvalidPrevHash);
}
if suite_id_from_u16(op.new_suite_id).is_none() {
return Err(OpError::UnsupportedSuite);
}
let mut scope = Vec::new();
op.encode_signed_scope(&mut scope);
// spec: ChangeKey подписано СТАРЫМ ключом (current_pubkey в state до apply)
if !verify_signed_scope(&scope, &op.signature, &sender.current_pubkey) {
return Err(OpError::InvalidSignature);
}
Ok(())
}
pub fn validate_anchor(op: &Anchor, state: &AccountTable) -> Result<(), OpError> {
let sender = state.get(&op.sender).ok_or(OpError::AccountNotFound)?;
if sender.frontier_hash != op.prev_hash {
return Err(OpError::InvalidPrevHash);
}
let mut scope = Vec::new();
op.encode_signed_scope(&mut scope);
if !verify_signed_scope(&scope, &op.signature, &sender.current_pubkey) {
return Err(OpError::InvalidSignature);
}
Ok(())
}
// Контекст валидации — обязательная обёртка consensus-зависимых параметров
// окна. Передаётся в generic validate(op, state, ctx). TransferActivation
// требует current_window + tau2_windows для cooldown check ([I-15] time-based
// scarcity, 1 активация на sender за τ₂). Остальные opcodes игнорируют
// context (поля доступны но не используются) — обязательность передачи
// гарантирует что caller не забудет про context при добавлении новых
// context-dependent операций в будущем.
#[derive(Clone, Copy, Debug)]
pub struct ValidationContext {
pub current_window: u64,
pub tau2_windows: u64,
}
pub fn validate(
op: &Operation,
state: &AccountTable,
ctx: &ValidationContext,
) -> Result<(), OpError> {
match op {
Operation::Transfer(inner) => validate_transfer(inner, state),
Operation::ChangeKey(inner) => validate_change_key(inner, state),
Operation::Anchor(inner) => validate_anchor(inner, state),
Operation::TransferActivation(inner) => {
validate_transfer_activation(inner, state, ctx.current_window, ctx.tau2_windows)
},
}
}
// spec: "State transition" + "Anti-inflation"
// apply_* assumes validated input (Phase B). expect() на protocol invariant
// violation — означает что apply вызван без предварительного validate (бага).
// spec v30.4.0+: TransferActivation создаёт AccountRecord для receiver от sender-а.
// Sender: balance -= amount, frontier_hash = op_hash, chain increments, last_activation_window = window_w.
// Receiver: новая запись с pubkey из payload, balance = amount, frontier_hash = 0x00 (genesis chain),
// last_activation_window = 0 (никогда не активировал).
// Hot-fix utility: AccountRecord использует u32 для window-полей (encoded size
// optimization), но apply_proposal передаёт window_w: u64 (consensus types).
// Cast safe до 4.29 млрд окон (~8000 лет at 60 sec/window). Beyond — protocol
// upgrade нужен.
fn window_w_to_u32(w: u64, context: &'static str) -> u32 {
u32::try_from(w).unwrap_or_else(|_| {
panic!(
"{context}: window_w = {w} > u32::MAX — encoded arithmetic horizon \
достигнут (~8000 лет at 60 sec/window), protocol upgrade required"
)
})
}
pub fn apply_transfer_activation(op: &TransferActivation, state: &mut AccountTable, window_w: u64) {
let frontier = op_hash(&Operation::TransferActivation(op.clone()));
let mut sender = state
.get(&op.sender)
.expect("protocol invariant: validate_transfer_activation ensures sender exists")
.clone();
// Checked arithmetic для defense-in-depth: validate_* гарантирует
// balance >= amount, но overflow protection остаётся как explicit halt
// на случай protocol invariant breach.
sender.balance = sender.balance.checked_sub(op.amount).unwrap_or_else(|| {
panic!(
"apply_transfer_activation: balance underflow — protocol invariant breach \
(validate_transfer_activation должен был отвергнуть op с balance={} < amount={})",
sender.balance, op.amount
)
});
sender.frontier_hash = frontier;
sender.op_height = sender.op_height.checked_add(1).unwrap_or_else(|| {
panic!("apply_transfer_activation: op_height overflow at u32::MAX — encoded arithmetic horizon")
});
sender.account_chain_length = sender
.account_chain_length
.checked_add(1)
.unwrap_or_else(|| {
panic!("apply_transfer_activation: account_chain_length overflow at u32::MAX")
});
sender.last_op_window = window_w_to_u32(window_w, "apply_transfer_activation last_op_window");
sender.last_activation_window =
window_w_to_u32(window_w, "apply_transfer_activation last_activation_window");
state.insert(sender);
let receiver_record = mt_state::AccountRecord {
account_id: op.receiver,
balance: op.amount,
suite_id: op.suite_id,
is_node_operator: false,
frontier_hash: [0u8; 32],
op_height: 0,
account_chain_length: 0,
account_chain_length_snapshot: 0,
current_pubkey: *op.receiver_pubkey.as_bytes(),
creation_window: window_w_to_u32(window_w, "apply_transfer_activation creation_window"),
last_op_window: window_w_to_u32(
window_w,
"apply_transfer_activation receiver last_op_window",
),
last_activation_window: 0,
};
state.insert(receiver_record);
}
pub fn apply_transfer(op: &Transfer, state: &mut AccountTable, window_w: u64) {
let frontier = op_hash(&Operation::Transfer(op.clone()));
// Sender update: balance -= amount, frontier, chain_length, op_height, last_op_window
let mut sender = state
.get(&op.sender)
.expect("protocol invariant: validate_transfer ensures sender exists")
.clone();
sender.balance = sender.balance.checked_sub(op.amount).unwrap_or_else(|| {
panic!(
"apply_transfer: balance underflow — protocol invariant breach \
(validate_transfer должен был отвергнуть op с balance={} < amount={})",
sender.balance, op.amount
)
});
sender.frontier_hash = frontier;
sender.op_height = sender
.op_height
.checked_add(1)
.unwrap_or_else(|| panic!("apply_transfer: op_height overflow at u32::MAX"));
sender.account_chain_length = sender
.account_chain_length
.checked_add(1)
.unwrap_or_else(|| panic!("apply_transfer: account_chain_length overflow at u32::MAX"));
sender.last_op_window = window_w_to_u32(window_w, "apply_transfer last_op_window");
state.insert(sender);
// Receiver update: ТОЛЬКО balance += amount (spec dep rule:
// "Получатель Transfer не получает обновления chain_length")
let mut receiver = state
.get(&op.link)
.expect("protocol invariant: validate_transfer ensures receiver exists")
.clone();
receiver.balance = receiver.balance.checked_add(op.amount).unwrap_or_else(|| {
panic!(
"apply_transfer: receiver balance overflow at u128::MAX (balance={}, amount={}) — \
encoded arithmetic horizon",
receiver.balance, op.amount
)
});
state.insert(receiver);
}
pub fn apply_change_key(op: &ChangeKey, state: &mut AccountTable, window_w: u64) {
let frontier = op_hash(&Operation::ChangeKey(op.clone()));
let mut sender = state
.get(&op.sender)
.expect("protocol invariant: validate_change_key ensures sender exists")
.clone();
sender.current_pubkey = *op.new_pubkey.as_bytes();
sender.suite_id = op.new_suite_id;
sender.frontier_hash = frontier;
sender.op_height = sender
.op_height
.checked_add(1)
.unwrap_or_else(|| panic!("apply_change_key: op_height overflow at u32::MAX"));
sender.account_chain_length = sender
.account_chain_length
.checked_add(1)
.unwrap_or_else(|| panic!("apply_change_key: account_chain_length overflow at u32::MAX"));
sender.last_op_window = window_w_to_u32(window_w, "apply_change_key last_op_window");
state.insert(sender);
}
pub fn apply_anchor(op: &Anchor, state: &mut AccountTable, window_w: u64) {
let frontier = op_hash(&Operation::Anchor(op.clone()));
// data_hash живёт в proposal chain, не в AccountTable — только frontier + chain_length update
let mut sender = state
.get(&op.sender)
.expect("protocol invariant: validate_anchor ensures sender exists")
.clone();
sender.frontier_hash = frontier;
sender.op_height = sender
.op_height
.checked_add(1)
.unwrap_or_else(|| panic!("apply_anchor: op_height overflow at u32::MAX"));
sender.account_chain_length = sender
.account_chain_length
.checked_add(1)
.unwrap_or_else(|| panic!("apply_anchor: account_chain_length overflow at u32::MAX"));
sender.last_op_window = window_w_to_u32(window_w, "apply_anchor last_op_window");
state.insert(sender);
}
pub fn apply(op: &Operation, state: &mut AccountTable, window_w: u64) {
match op {
Operation::Transfer(inner) => apply_transfer(inner, state, window_w),
Operation::ChangeKey(inner) => apply_change_key(inner, state, window_w),
Operation::Anchor(inner) => apply_anchor(inner, state, window_w),
Operation::TransferActivation(inner) => apply_transfer_activation(inner, state, window_w),
}
}
// spec: "Эмиссия" — const emission `reward_moneta(W) = EMISSION_moneta`.
use mt_genesis::ProtocolParams;
/// reward(W) = EMISSION_moneta — константа из ProtocolParams.
pub fn reward_moneta(params: &ProtocolParams) -> u128 {
params.emission_moneta
}
/// Total emitted supply over windows [0, window] inclusive — closed-form.
/// `supply_moneta(W) = EMISSION_moneta × (W + 1)`.
pub fn supply_moneta(window: u64, params: &ProtocolParams) -> u128 {
params.emission_moneta * (u128::from(window) + 1)
}
// spec: "State transition → apply_proposal" steps 2, 3.5, 3.6, 4.
// Steps 1, 3a, 3b stubbed до M4 (NodeRegistration/candidate expiry/selection event).
pub use mt_state::WINNER_CLASS_NODE;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProposalSettle {
pub window_w: u64,
pub winner_id: [u8; 32],
pub cemented_confirmers: Vec<NodeId>,
}
// spec: "settle (apply at window close)" — cemented UserObjects окна W
// применяются батчем в порядке op_hash lex asc.
pub fn settle_window(state: &mut AccountTable, cemented_ops: &[Operation], window_w: u64) {
let mut indexed: Vec<(Hash32, &Operation)> =
cemented_ops.iter().map(|op| (op_hash(op), op)).collect();
indexed.sort_by_key(|(h, _)| *h);
for (_, op) in indexed {
apply(op, state, window_w);
}
}
// Step 2: reward emission — winner_{W-1} получает EMISSION_moneta.
// spec Sovereignty Ladder: лотерея single-class, winner всегда узел;
// reward зачисляется на operator_account_id узла-winner-а.
//
// Protocol invariant: для любого NodeRecord в NodeTable, поле
// operator_account_id обязано указывать на existing AccountRecord в
// AccountTable. Нарушение invariant → panic в apply_emission, защита
// через explicit panic не silent skip — corrupted NodeTable гарантирует
// fork и должна быть обнаружена немедленно.
fn apply_emission(
account_table: &mut AccountTable,
node_table: &NodeTable,
window_w: u64,
winner_id: &[u8; 32],
params: &ProtocolParams,
) {
if window_w == 0 {
return; // genesis: нет W-1
}
let reward = reward_moneta(params);
let node = node_table
.get(winner_id)
.expect("protocol invariant: winner node exists in NodeTable");
let operator_id = node.operator_account_id;
let mut operator = account_table
.get(&operator_id)
.expect("protocol invariant: operator account exists")
.clone();
operator.balance = operator.balance.checked_add(reward).unwrap_or_else(|| {
panic!(
"apply_emission: operator balance overflow at u128::MAX (balance={}, reward={}) — \
encoded arithmetic horizon",
operator.balance, reward
)
});
account_table.insert(operator);
}
// Step 3.5: chain_length++ для узлов с cemented BundledConfirmation в окне W.
// Checked arithmetic для consistency с apply_transfer / apply_change_key /
// apply_anchor / apply_emission / apply_transfer_activation. u64 overflow
// horizon ~3.5×10^11 лет at 60 sec/window — practically unreachable, panic
// = explicit halt при protocol invariant breach.
fn apply_chain_length_increment(node_table: &mut NodeTable, confirmers: &[NodeId], window_w: u64) {
for node_id in confirmers {
if let Some(existing) = node_table.get(node_id) {
let mut node = existing.clone();
node.chain_length = node.chain_length.checked_add(1).unwrap_or_else(|| {
panic!(
"apply_chain_length_increment: chain_length overflow at u64::MAX \
encoded arithmetic horizon (~3.5×10^11 лет at 60 sec/window)"
)
});
node.last_confirmation_window = window_w;
node_table.insert(node);
}
}
}
// Step 3.6: rotate chain_length_checkpoints на τ₂-boundary.
// Shift: oldest (index 0) выбывает, остальные сдвигаются, newest (5) = current chain_length.
// chain_length_snapshot = chain_length - checkpoints[0] (самый старый после ротации).
// Checked subtraction защищает от protocol invariant breach: rotation logic
// поддерживает checkpoints[0] ≤ chain_length всегда (newest = current,
// shift left → старые ≤ текущего). Underflow означает corrupted state либо
// bug в rotation invariant — panic, не silent wrap до u64::MAX.
fn apply_checkpoint_rotation(node_table: &mut NodeTable, window_w: u64, params: &ProtocolParams) {
if window_w == 0 || window_w % params.tau2_windows != 0 {
return;
}
let snapshot: Vec<NodeRecord> = node_table.iter().cloned().collect();
for node in snapshot {
let mut rotated = node.clone();
for i in 0..5 {
rotated.chain_length_checkpoints[i] = rotated.chain_length_checkpoints[i + 1];
}
rotated.chain_length_checkpoints[5] = rotated.chain_length;
rotated.chain_length_snapshot = rotated
.chain_length
.checked_sub(rotated.chain_length_checkpoints[0])
.unwrap_or_else(|| {
panic!(
"apply_checkpoint_rotation: invariant breach — checkpoints[0] ({}) > \
chain_length ({}) rotation logic corrupted",
rotated.chain_length_checkpoints[0], rotated.chain_length
)
});
node_table.insert(rotated);
}
}
// spec: "Вход и регистрация → Genesis State" (строки 1468-1502)
//
// Genesis State — аксиома сети: 1 bootstrap account (is_node_operator=true, balance=0)
// + 1 bootstrap node (chain_length=1 для инварианта weighted_ticket) + empty Candidate Pool.
pub const GENESIS_SUITE_ID: u16 = 1;
pub struct GenesisState {
pub account_table: AccountTable,
pub node_table: NodeTable,
pub candidate_pool: CandidatePool,
}
pub fn build_genesis_state(params: &ProtocolParams) -> GenesisState {
let account_id = derive_account_id(GENESIS_SUITE_ID, &params.bootstrap_account_pubkey);
let node_id = derive_node_id(&params.bootstrap_node_pubkey);
// spec: frontier_hash = SHA-256("mt-genesis" || account_id)
let frontier = hash(domain::GENESIS, &[&account_id]);
let account = AccountRecord {
account_id,
balance: 0,
suite_id: GENESIS_SUITE_ID,
is_node_operator: true,
frontier_hash: frontier,
op_height: 0,
account_chain_length: 0,
account_chain_length_snapshot: 0,
current_pubkey: params.bootstrap_account_pubkey,
creation_window: 0,
last_op_window: 0,
last_activation_window: 0,
};
let node = NodeRecord {
node_id,
node_pubkey: params.bootstrap_node_pubkey,
suite_id: GENESIS_SUITE_ID,
operator_account_id: account_id,
start_window: 0,
chain_length: 1, // spec: invariant chain_length ≥ 1
chain_length_snapshot: 0,
chain_length_checkpoints: [0u64; 6],
last_confirmation_window: 0,
};
let mut account_table = AccountTable::new();
account_table.insert(account);
let mut node_table = NodeTable::new();
node_table.insert(node);
let candidate_pool = CandidatePool::new();
GenesisState {
account_table,
node_table,
candidate_pool,
}
}
pub fn genesis_state_root(state: &GenesisState) -> Hash32 {
compute_state_root(
&state.node_table.root(),
&state.candidate_pool.root(),
&state.account_table.root(),
)
}
// spec, "State transition → apply_proposal" — orchestration steps 2/3.5/3.6/4.
//
// Settle (cemented user ops apply через `settle_window`) — выполняется ВНЕ
// apply_proposal, design choice: caller (M4 mt-consensus orchestrator) вызывает
// settle_window(account_table, cemented_ops, window_w) ДО apply_proposal —
// cemented user operations должны применяться к state ПЕРЕД emission, чтобы
// balance изменения видны в reward account update.
//
// Steps 1, 3a, 3b stubbed (M4 mt-entry: NodeRegistration batch / candidate
// expiry / selection event) — orchestration tracker в M4.
pub fn apply_proposal(
account_table: &mut AccountTable,
node_table: &mut NodeTable,
candidate_pool: &CandidatePool,
input: &ProposalSettle,
params: &ProtocolParams,
) -> Hash32 {
// Step 1 stub: control_set (ControlObjects = NodeRegistrations) — M4 (mt-entry).
// Step 2: эмиссия за окно W-1 — константа EMISSION_moneta.
apply_emission(
account_table,
node_table,
input.window_w,
&input.winner_id,
params,
);
// Step 3a, 3b stubs: candidate expiry + selection event — M4 (mt-entry).
// Step 3.5:
apply_chain_length_increment(node_table, &input.cemented_confirmers, input.window_w);
// Step 3.6:
apply_checkpoint_rotation(node_table, input.window_w, params);
// Step 4: state_root.
compute_state_root(
&node_table.root(),
&candidate_pool.root(),
&account_table.root(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use sha2::{Digest, Sha256};
fn sample_pubkey(seed: u8) -> PublicKey {
PublicKey::from_array([seed; PUBLIC_KEY_SIZE])
}
fn sample_signature(seed: u8) -> Signature {
Signature::from_array([seed; SIGNATURE_SIZE])
}
fn sample_transfer_activation() -> TransferActivation {
let pk = sample_pubkey(0xAA);
let receiver = derive_account_id(0x0001, pk.as_bytes());
TransferActivation {
prev_hash: [0x10u8; 32],
sender: [0x20u8; 32],
receiver,
suite_id: 0x0001,
receiver_pubkey: pk,
amount: 500_000_000_000u128,
signature: sample_signature(0xBB),
}
}
fn sample_transfer() -> Transfer {
Transfer {
prev_hash: [0x11u8; 32],
sender: [0x22u8; 32],
link: [0x33u8; 32],
amount: 1_000_000_000u128,
signature: sample_signature(0xCC),
}
}
fn sample_change_key() -> ChangeKey {
ChangeKey {
prev_hash: [0x44u8; 32],
sender: [0x55u8; 32],
new_suite_id: 0x0001,
new_pubkey: sample_pubkey(0xDD),
signature: sample_signature(0xEE),
}
}
fn sample_anchor() -> Anchor {
Anchor {
prev_hash: [0x66u8; 32],
sender: [0x77u8; 32],
app_id: [0x88u8; 32],
data_hash: [0x99u8; 32],
signature: sample_signature(0xAB),
}
}
#[test]
fn transfer_activation_encodes_to_expected_size() {
let mut buf = Vec::new();
sample_transfer_activation().encode(&mut buf);
assert_eq!(buf.len(), TRANSFER_ACTIVATION_SIZE);
// type 1 + prev_hash 32 + sender 32 + receiver 32 + suite_id 2
// + receiver_pubkey 1952 (ML-DSA-65) + amount 16 + signature 3309 = 5376
assert_eq!(TRANSFER_ACTIVATION_SIZE, 5376);
}
#[test]
fn transfer_encodes_to_expected_size() {
let mut buf = Vec::new();
sample_transfer().encode(&mut buf);
assert_eq!(buf.len(), TRANSFER_SIZE);
// type 1 + prev_hash 32 + sender 32 + link 32 + amount 16
// + signature 3309 (ML-DSA-65) = 3422
assert_eq!(TRANSFER_SIZE, 3422);
}
#[test]
fn change_key_encodes_to_expected_size() {
let mut buf = Vec::new();
sample_change_key().encode(&mut buf);
assert_eq!(buf.len(), CHANGE_KEY_SIZE);
// type 1 + prev_hash 32 + sender 32 + new_suite_id 2
// + new_pubkey 1952 + signature 3309 = 5328
assert_eq!(CHANGE_KEY_SIZE, 5328);
}
#[test]
fn anchor_encodes_to_expected_size() {
let mut buf = Vec::new();
sample_anchor().encode(&mut buf);
assert_eq!(buf.len(), ANCHOR_SIZE);
// type 1 + prev_hash 32 + sender 32 + app_id 32 + data_hash 32
// + signature 3309 = 3438
assert_eq!(ANCHOR_SIZE, 3438);
}
#[test]
fn first_byte_is_type_code() {
let mut b2 = Vec::new();
sample_transfer().encode(&mut b2);
assert_eq!(b2[0], TYPE_TRANSFER);
let mut b3 = Vec::new();
sample_change_key().encode(&mut b3);
assert_eq!(b3[0], TYPE_CHANGE_KEY);
let mut b4 = Vec::new();
sample_anchor().encode(&mut b4);
assert_eq!(b4[0], TYPE_ANCHOR);
let mut b5 = Vec::new();
sample_transfer_activation().encode(&mut b5);
assert_eq!(b5[0], TYPE_TRANSFER_ACTIVATION);
}
#[test]
fn prev_hash_is_bytes_1_through_32() {
let mut buf = Vec::new();
sample_transfer().encode(&mut buf);
assert_eq!(&buf[1..33], &[0x11u8; 32]);
}
#[test]
fn transfer_amount_little_endian() {
let mut buf = Vec::new();
sample_transfer().encode(&mut buf);
// type(1) + prev_hash(32) + sender(32) + link(32) = offset 97
let amount_bytes = &buf[97..97 + 16];
assert_eq!(amount_bytes, &1_000_000_000u128.to_le_bytes());
}
#[test]
fn transfer_field_order_sender_link_amount() {
let mut buf = Vec::new();
sample_transfer().encode(&mut buf);
assert_eq!(&buf[33..65], &[0x22u8; 32]); // sender
assert_eq!(&buf[65..97], &[0x33u8; 32]); // link
}
#[test]
fn change_key_field_order() {
let mut buf = Vec::new();
sample_change_key().encode(&mut buf);
assert_eq!(&buf[33..65], &[0x55u8; 32]); // sender
assert_eq!(u16::from_le_bytes([buf[65], buf[66]]), 0x0001); // new_suite_id
assert_eq!(&buf[67..67 + PUBLIC_KEY_SIZE], &[0xDDu8; PUBLIC_KEY_SIZE]); // new_pubkey
}
#[test]
fn anchor_field_order() {
let mut buf = Vec::new();
sample_anchor().encode(&mut buf);
assert_eq!(&buf[33..65], &[0x77u8; 32]); // sender
assert_eq!(&buf[65..97], &[0x88u8; 32]); // app_id
assert_eq!(&buf[97..129], &[0x99u8; 32]); // data_hash
}
#[test]
fn operation_enum_delegates_to_each_variant() {
let cases: [(Operation, Vec<u8>); 4] = [
(Operation::Transfer(sample_transfer()), {
let mut b = Vec::new();
sample_transfer().encode(&mut b);
b
}),
(Operation::ChangeKey(sample_change_key()), {
let mut b = Vec::new();
sample_change_key().encode(&mut b);
b
}),
(Operation::Anchor(sample_anchor()), {
let mut b = Vec::new();
sample_anchor().encode(&mut b);
b
}),
(
Operation::TransferActivation(sample_transfer_activation()),
{
let mut b = Vec::new();
sample_transfer_activation().encode(&mut b);
b
},
),
];
for (op, expected) in cases {
let mut via_enum = Vec::new();
op.encode(&mut via_enum);
assert_eq!(via_enum, expected);
}
}
#[test]
fn op_hash_is_deterministic() {
let op = Operation::Transfer(sample_transfer());
assert_eq!(op_hash(&op), op_hash(&op));
}
#[test]
fn op_hash_uses_mt_op_domain_over_signed_scope() {
// Правило R2: identifier(op) = hash("mt-op", [signed_scope(op)])
// = SHA-256("mt-op" || 0x00 || signed_scope)
// NUL byte separator — self-delimiting domain separation (spec v29.13.0).
// signed_scope = canonical_bytes без signature (last SIGNATURE_SIZE bytes).
let op = Operation::Transfer(sample_transfer());
let mut signed_scope = Vec::new();
op.encode_signed_scope(&mut signed_scope);
let mut hasher = Sha256::new();
hasher.update(b"mt-op");
hasher.update([0u8]); // NUL separator per canonical hash primitive
hasher.update(&signed_scope);
let expected: Hash32 = hasher.finalize().into();
assert_eq!(op_hash(&op), expected);
}
#[test]
fn op_hash_stable_under_signature_mutation() {
// Positive test for SSI Правило R2: op_hash не зависит от σ.
// ML-DSA-65 в Montana работает в deterministic variant, поэтому повторный
// sign даёт ту же σ — но R2 не должен полагаться на это свойство:
// identifier(op) обязан быть идентичным даже при произвольной мутации σ.
let mut t1 = sample_transfer();
let t1_hash = op_hash(&Operation::Transfer(t1.clone()));
// Симулируем re-sign того же logical op другой randomness → другая σ
t1.signature = Signature::from_array([0xFFu8; SIGNATURE_SIZE]);
let t2_hash = op_hash(&Operation::Transfer(t1));
assert_eq!(
t1_hash, t2_hash,
"op_hash must be stable under signature change (SSI R2)"
);
}
#[test]
fn signed_scope_excludes_signature() {
// SSI Правило R1: signed_scope = canonical_bytes без последних SIGNATURE_SIZE байт.
let op = sample_transfer();
let mut canonical = Vec::new();
op.encode(&mut canonical);
let mut scope = Vec::new();
op.encode_signed_scope(&mut scope);
assert_eq!(canonical.len(), TRANSFER_SIZE);
assert_eq!(scope.len(), TRANSFER_SIZE - SIGNATURE_SIZE);
assert_eq!(&canonical[..scope.len()], scope.as_slice());
}
#[test]
fn different_operations_produce_different_hashes() {
let h1 = op_hash(&Operation::TransferActivation(sample_transfer_activation()));
let h2 = op_hash(&Operation::Transfer(sample_transfer()));
let h3 = op_hash(&Operation::ChangeKey(sample_change_key()));
let h4 = op_hash(&Operation::Anchor(sample_anchor()));
assert_ne!(h1, h2);
assert_ne!(h1, h3);
assert_ne!(h1, h4);
assert_ne!(h2, h3);
assert_ne!(h2, h4);
assert_ne!(h3, h4);
}
#[test]
fn mutated_field_changes_op_hash() {
let mut t = sample_transfer();
let h_before = op_hash(&Operation::Transfer(t.clone()));
t.amount += 1;
let h_after = op_hash(&Operation::Transfer(t));
assert_ne!(h_before, h_after);
}
#[test]
fn signature_position_is_last_signature_size_bytes() {
let mut buf = Vec::new();
sample_transfer().encode(&mut buf);
let sig_start = buf.len() - SIGNATURE_SIZE;
assert_eq!(&buf[sig_start..], &[0xCCu8; SIGNATURE_SIZE]);
}
#[test]
fn type_codes_are_stable() {
// type byte 0x01 не выделен (OpenAccount удалён)
assert_eq!(TYPE_TRANSFER, 0x02);
assert_eq!(TYPE_CHANGE_KEY, 0x03);
assert_eq!(TYPE_ANCHOR, 0x04);
assert_eq!(TYPE_TRANSFER_ACTIVATION, 0x0A);
}
// ================== Phase B: validation ==================
use mt_crypto::{keypair, sign, SecretKey};
use mt_state::AccountRecord;
const MLDSA_SUITE: u16 = 0x0001;
fn make_account_record(
pubkey_bytes: &[u8; PUBLIC_KEY_SIZE],
suite_id: u16,
balance: u128,
frontier: Hash32,
) -> AccountRecord {
let account_id = derive_account_id(suite_id, pubkey_bytes);
AccountRecord {
account_id,
balance,
suite_id,
is_node_operator: false,
frontier_hash: frontier,
op_height: 1,
account_chain_length: 1,
account_chain_length_snapshot: 1,
current_pubkey: *pubkey_bytes,
creation_window: 0,
last_op_window: 0,
last_activation_window: 0,
}
}
fn sign_op<F>(sk: &SecretKey, encode_scope: F) -> Signature
where
F: FnOnce(&mut Vec<u8>),
{
let mut scope = Vec::new();
encode_scope(&mut scope);
sign(sk, &scope).expect("sign op scope")
}
// ---- TransferActivation ----
#[test]
fn validate_transfer_activation_happy() {
let (sender_pk, sender_sk) = keypair();
let sender_id = derive_account_id(MLDSA_SUITE, sender_pk.as_bytes());
let mut state = AccountTable::new();
state.insert(make_account_record(
sender_pk.as_bytes(),
MLDSA_SUITE,
1_000_000_000,
[0u8; 32],
));
let (receiver_pk, _) = keypair();
let receiver_id = derive_account_id(MLDSA_SUITE, receiver_pk.as_bytes());
let mut op = TransferActivation {
prev_hash: [0u8; 32],
sender: sender_id,
receiver: receiver_id,
suite_id: MLDSA_SUITE,
receiver_pubkey: receiver_pk,
amount: 100,
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
};
op.signature = sign_op(&sender_sk, |b| op.encode_signed_scope(b));
assert_eq!(
validate_transfer_activation(&op, &state, 1_000, 20_160),
Ok(())
);
}
#[test]
fn validate_transfer_activation_rejects_existing_receiver() {
let (sender_pk, _) = keypair();
let sender_id = derive_account_id(MLDSA_SUITE, sender_pk.as_bytes());
let (receiver_pk, _) = keypair();
let receiver_id = derive_account_id(MLDSA_SUITE, receiver_pk.as_bytes());
let mut state = AccountTable::new();
state.insert(make_account_record(
sender_pk.as_bytes(),
MLDSA_SUITE,
1_000,
[0u8; 32],
));
state.insert(make_account_record(
receiver_pk.as_bytes(),
MLDSA_SUITE,
0,
[0u8; 32],
));
let op = TransferActivation {
prev_hash: [0u8; 32],
sender: sender_id,
receiver: receiver_id,
suite_id: MLDSA_SUITE,
receiver_pubkey: receiver_pk,
amount: 100,
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
};
assert_eq!(
validate_transfer_activation(&op, &state, 1_000, 20_160),
Err(OpError::ReceiverAlreadyExists)
);
}
#[test]
fn validate_transfer_activation_rejects_bad_binding() {
let (sender_pk, _) = keypair();
let sender_id = derive_account_id(MLDSA_SUITE, sender_pk.as_bytes());
let (receiver_pk, _) = keypair();
let mut state = AccountTable::new();
state.insert(make_account_record(
sender_pk.as_bytes(),
MLDSA_SUITE,
1_000,
[0u8; 32],
));
let op = TransferActivation {
prev_hash: [0u8; 32],
sender: sender_id,
receiver: [0xAAu8; 32], // не SHA-256 от receiver_pubkey
suite_id: MLDSA_SUITE,
receiver_pubkey: receiver_pk,
amount: 100,
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
};
assert_eq!(
validate_transfer_activation(&op, &state, 1_000, 20_160),
Err(OpError::InvalidBinding)
);
}
#[test]
fn validate_transfer_activation_rejects_cooldown_not_elapsed() {
let (sender_pk, _) = keypair();
let sender_id = derive_account_id(MLDSA_SUITE, sender_pk.as_bytes());
let mut sender_rec =
make_account_record(sender_pk.as_bytes(), MLDSA_SUITE, 1_000, [0u8; 32]);
// sender уже активировал кого-то в окне 500; cooldown τ₂ = 20_160.
sender_rec.last_activation_window = 500;
let mut state = AccountTable::new();
state.insert(sender_rec);
let (receiver_pk, _) = keypair();
let receiver_id = derive_account_id(MLDSA_SUITE, receiver_pk.as_bytes());
let op = TransferActivation {
prev_hash: [0u8; 32],
sender: sender_id,
receiver: receiver_id,
suite_id: MLDSA_SUITE,
receiver_pubkey: receiver_pk,
amount: 100,
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
};
// current_window = 1000; 1000 < 500 + 20_160 → reject
assert_eq!(
validate_transfer_activation(&op, &state, 1_000, 20_160),
Err(OpError::ActivationCooldownNotElapsed)
);
}
// ---- Transfer ----
struct TransferFixture {
sender_sk: SecretKey,
state: AccountTable,
sender_id: AccountId,
receiver_id: AccountId,
frontier: Hash32,
}
fn setup_transfer() -> TransferFixture {
let (sender_pk, sender_sk) = keypair();
let (receiver_pk, _) = keypair();
let frontier = [0x77u8; 32];
let sender_record =
make_account_record(sender_pk.as_bytes(), MLDSA_SUITE, 1_000_000, frontier);
let receiver_record =
make_account_record(receiver_pk.as_bytes(), MLDSA_SUITE, 0, [0u8; 32]);
let sender_id = sender_record.account_id;
let receiver_id = receiver_record.account_id;
let mut state = AccountTable::new();
state.insert(sender_record);
state.insert(receiver_record);
TransferFixture {
sender_sk,
state,
sender_id,
receiver_id,
frontier,
}
}
fn signed_transfer(fx: &TransferFixture, amount: u128) -> Transfer {
let mut op = Transfer {
prev_hash: fx.frontier,
sender: fx.sender_id,
link: fx.receiver_id,
amount,
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
};
op.signature = sign_op(&fx.sender_sk, |b| op.encode_signed_scope(b));
op
}
#[test]
fn validate_transfer_happy() {
let fx = setup_transfer();
let op = signed_transfer(&fx, 100);
assert_eq!(validate_transfer(&op, &fx.state), Ok(()));
}
#[test]
fn validate_transfer_rejects_missing_sender() {
let fx = setup_transfer();
let mut op = signed_transfer(&fx, 100);
op.sender = [0xEEu8; 32];
assert_eq!(
validate_transfer(&op, &fx.state),
Err(OpError::AccountNotFound)
);
}
#[test]
fn validate_transfer_rejects_wrong_prev_hash() {
let fx = setup_transfer();
let mut op = signed_transfer(&fx, 100);
op.prev_hash = [0x11u8; 32];
assert_eq!(
validate_transfer(&op, &fx.state),
Err(OpError::InvalidPrevHash)
);
}
#[test]
fn validate_transfer_rejects_self_transfer() {
let fx = setup_transfer();
let mut op = signed_transfer(&fx, 100);
op.link = op.sender;
assert_eq!(
validate_transfer(&op, &fx.state),
Err(OpError::SelfTransfer)
);
}
#[test]
fn validate_transfer_rejects_zero_amount() {
let fx = setup_transfer();
let op = signed_transfer(&fx, 0);
assert_eq!(validate_transfer(&op, &fx.state), Err(OpError::ZeroAmount));
}
#[test]
fn validate_transfer_rejects_insufficient_balance() {
let fx = setup_transfer();
let op = signed_transfer(&fx, 10_000_000);
assert_eq!(
validate_transfer(&op, &fx.state),
Err(OpError::InsufficientBalance)
);
}
#[test]
fn validate_transfer_rejects_missing_receiver() {
// spec v30.x: Transfer reject ReceiverNotActive если receiver ∉ AccountTable
// (новые аккаунты создаются только через TransferActivation)
let fx = setup_transfer();
let mut op = signed_transfer(&fx, 100);
op.link = [0xFFu8; 32];
assert_eq!(
validate_transfer(&op, &fx.state),
Err(OpError::ReceiverNotActive)
);
}
#[test]
fn validate_transfer_rejects_bad_signature() {
let fx = setup_transfer();
let mut op = signed_transfer(&fx, 100);
op.signature = Signature::from_array([0u8; SIGNATURE_SIZE]);
assert_eq!(
validate_transfer(&op, &fx.state),
Err(OpError::InvalidSignature)
);
}
// ---- ChangeKey ----
#[test]
fn validate_change_key_happy() {
let (old_pk, old_sk) = keypair();
let (new_pk, _) = keypair();
let frontier = [0x33u8; 32];
let record = make_account_record(old_pk.as_bytes(), MLDSA_SUITE, 0, frontier);
let sender_id = record.account_id;
let mut state = AccountTable::new();
state.insert(record);
let mut op = ChangeKey {
prev_hash: frontier,
sender: sender_id,
new_suite_id: MLDSA_SUITE,
new_pubkey: new_pk,
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
};
op.signature = sign_op(&old_sk, |b| op.encode_signed_scope(b));
assert_eq!(validate_change_key(&op, &state), Ok(()));
}
#[test]
fn validate_change_key_rejects_missing_sender() {
let (new_pk, _) = keypair();
let op = ChangeKey {
prev_hash: [0u8; 32],
sender: [0xABu8; 32],
new_suite_id: MLDSA_SUITE,
new_pubkey: new_pk,
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
};
let state = AccountTable::new();
assert_eq!(
validate_change_key(&op, &state),
Err(OpError::AccountNotFound)
);
}
#[test]
fn validate_change_key_rejects_wrong_prev_hash() {
let (old_pk, _) = keypair();
let (new_pk, _) = keypair();
let frontier = [0x33u8; 32];
let record = make_account_record(old_pk.as_bytes(), MLDSA_SUITE, 0, frontier);
let sender_id = record.account_id;
let mut state = AccountTable::new();
state.insert(record);
let op = ChangeKey {
prev_hash: [0x22u8; 32], // != frontier
sender: sender_id,
new_suite_id: MLDSA_SUITE,
new_pubkey: new_pk,
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
};
assert_eq!(
validate_change_key(&op, &state),
Err(OpError::InvalidPrevHash)
);
}
#[test]
fn validate_change_key_rejects_unsupported_new_suite() {
let (old_pk, old_sk) = keypair();
let (new_pk, _) = keypair();
let frontier = [0x33u8; 32];
let record = make_account_record(old_pk.as_bytes(), MLDSA_SUITE, 0, frontier);
let sender_id = record.account_id;
let mut state = AccountTable::new();
state.insert(record);
let mut op = ChangeKey {
prev_hash: frontier,
sender: sender_id,
new_suite_id: 0xDEAD,
new_pubkey: new_pk,
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
};
op.signature = sign_op(&old_sk, |b| op.encode_signed_scope(b));
assert_eq!(
validate_change_key(&op, &state),
Err(OpError::UnsupportedSuite)
);
}
#[test]
fn validate_change_key_rejects_signature_by_new_key_not_old() {
// SSI R1: ChangeKey должен быть подписан СТАРЫМ ключом, подпись новым — invalid
let (old_pk, _old_sk) = keypair();
let (new_pk, new_sk) = keypair();
let frontier = [0x33u8; 32];
let record = make_account_record(old_pk.as_bytes(), MLDSA_SUITE, 0, frontier);
let sender_id = record.account_id;
let mut state = AccountTable::new();
state.insert(record);
let mut op = ChangeKey {
prev_hash: frontier,
sender: sender_id,
new_suite_id: MLDSA_SUITE,
new_pubkey: new_pk,
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
};
// подписываем НОВЫМ ключом — должно провалиться
op.signature = sign_op(&new_sk, |b| op.encode_signed_scope(b));
assert_eq!(
validate_change_key(&op, &state),
Err(OpError::InvalidSignature)
);
}
// ---- Anchor ----
#[test]
fn validate_anchor_happy() {
let (pk, sk) = keypair();
let frontier = [0x44u8; 32];
let record = make_account_record(pk.as_bytes(), MLDSA_SUITE, 0, frontier);
let sender_id = record.account_id;
let mut state = AccountTable::new();
state.insert(record);
let mut op = Anchor {
prev_hash: frontier,
sender: sender_id,
app_id: [0x88u8; 32],
data_hash: [0x99u8; 32],
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
};
op.signature = sign_op(&sk, |b| op.encode_signed_scope(b));
assert_eq!(validate_anchor(&op, &state), Ok(()));
}
#[test]
fn validate_anchor_rejects_missing_sender() {
let op = Anchor {
prev_hash: [0u8; 32],
sender: [0xCDu8; 32],
app_id: [0x88u8; 32],
data_hash: [0x99u8; 32],
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
};
let state = AccountTable::new();
assert_eq!(validate_anchor(&op, &state), Err(OpError::AccountNotFound));
}
#[test]
fn validate_anchor_rejects_wrong_prev_hash() {
let (pk, _sk) = keypair();
let record = make_account_record(pk.as_bytes(), MLDSA_SUITE, 0, [0x44u8; 32]);
let sender_id = record.account_id;
let mut state = AccountTable::new();
state.insert(record);
let op = Anchor {
prev_hash: [0x00u8; 32],
sender: sender_id,
app_id: [0x88u8; 32],
data_hash: [0x99u8; 32],
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
};
assert_eq!(validate_anchor(&op, &state), Err(OpError::InvalidPrevHash));
}
#[test]
fn validate_anchor_rejects_bad_signature() {
let (pk, _sk) = keypair();
let frontier = [0x44u8; 32];
let record = make_account_record(pk.as_bytes(), MLDSA_SUITE, 0, frontier);
let sender_id = record.account_id;
let mut state = AccountTable::new();
state.insert(record);
let op = Anchor {
prev_hash: frontier,
sender: sender_id,
app_id: [0x88u8; 32],
data_hash: [0x99u8; 32],
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
};
assert_eq!(validate_anchor(&op, &state), Err(OpError::InvalidSignature));
}
// ---- dispatcher ----
#[test]
fn validate_dispatcher_delegates() {
let fx = setup_transfer();
let op = Operation::Transfer(signed_transfer(&fx, 100));
let ctx = ValidationContext {
current_window: 0,
tau2_windows: 1,
};
assert_eq!(validate(&op, &fx.state, &ctx), Ok(()));
}
#[test]
fn validate_dispatcher_enforces_cooldown_for_transfer_activation() {
// Anti-regression M3-A-4: generic validate(op, state, ctx) для
// TransferActivation НЕ должен silent bypass cooldown. Если sender
// уже активировал недавно — generic dispatcher обязан вернуть
// ActivationCooldownNotElapsed с production-like context.
let (sender_pk, sender_sk) = keypair();
let sender_id = derive_account_id(MLDSA_SUITE, sender_pk.as_bytes());
let (receiver_pk, _) = keypair();
let receiver_id = derive_account_id(MLDSA_SUITE, receiver_pk.as_bytes());
// sender уже активировал в окне 100; tau2 = 1000; current = 500
// → 500 < 100 + 1000 = 1100 → cooldown активен.
let mut state = AccountTable::new();
state.insert(AccountRecord {
account_id: sender_id,
balance: 1000,
suite_id: MLDSA_SUITE,
is_node_operator: false,
frontier_hash: [9u8; 32],
op_height: 5,
account_chain_length: 5,
account_chain_length_snapshot: 5,
current_pubkey: *sender_pk.as_bytes(),
creation_window: 0,
last_op_window: 100,
last_activation_window: 100,
});
let mut activation = TransferActivation {
prev_hash: [9u8; 32],
sender: sender_id,
receiver: receiver_id,
suite_id: MLDSA_SUITE,
receiver_pubkey: receiver_pk.clone(),
amount: 50,
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
};
let mut scope = Vec::new();
activation.encode_signed_scope(&mut scope);
activation.signature = mt_crypto::sign(&sender_sk, &scope).unwrap();
let op = Operation::TransferActivation(activation);
let ctx = ValidationContext {
current_window: 500,
tau2_windows: 1000,
};
assert_eq!(
validate(&op, &state, &ctx),
Err(OpError::ActivationCooldownNotElapsed)
);
}
// ================== Phase C: apply ==================
const TEST_WINDOW: u64 = 42;
const TEST_WINDOW_U32: u32 = 42; // для assertions против AccountRecord fields (u32)
#[test]
fn apply_transfer_activation_creates_receiver_record() {
let (sender_pk, _) = keypair();
let sender_id = derive_account_id(MLDSA_SUITE, sender_pk.as_bytes());
let (receiver_pk, _) = keypair();
let receiver_id = derive_account_id(MLDSA_SUITE, receiver_pk.as_bytes());
let mut state = AccountTable::new();
state.insert(make_account_record(
sender_pk.as_bytes(),
MLDSA_SUITE,
1_000_000,
[0u8; 32],
));
let op = TransferActivation {
prev_hash: [0u8; 32],
sender: sender_id,
receiver: receiver_id,
suite_id: MLDSA_SUITE,
receiver_pubkey: receiver_pk.clone(),
amount: 100_000,
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
};
apply_transfer_activation(&op, &mut state, TEST_WINDOW);
let receiver = state.get(&receiver_id).expect("receiver must exist");
assert_eq!(receiver.balance, 100_000);
assert_eq!(receiver.suite_id, MLDSA_SUITE);
assert_eq!(receiver.current_pubkey, *receiver_pk.as_bytes());
assert_eq!(receiver.frontier_hash, [0u8; 32]);
assert_eq!(receiver.last_activation_window, 0);
}
#[test]
fn apply_transfer_activation_updates_sender_cooldown() {
let (sender_pk, _) = keypair();
let sender_id = derive_account_id(MLDSA_SUITE, sender_pk.as_bytes());
let (receiver_pk, _) = keypair();
let receiver_id = derive_account_id(MLDSA_SUITE, receiver_pk.as_bytes());
let mut state = AccountTable::new();
state.insert(make_account_record(
sender_pk.as_bytes(),
MLDSA_SUITE,
1_000_000,
[0u8; 32],
));
let op = TransferActivation {
prev_hash: [0u8; 32],
sender: sender_id,
receiver: receiver_id,
suite_id: MLDSA_SUITE,
receiver_pubkey: receiver_pk,
amount: 42,
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
};
apply_transfer_activation(&op, &mut state, TEST_WINDOW);
let sender = state.get(&sender_id).unwrap();
assert_eq!(sender.last_activation_window, TEST_WINDOW_U32);
assert_eq!(sender.balance, 1_000_000 - 42);
}
#[test]
fn apply_transfer_debits_sender_credits_receiver() {
let fx = setup_transfer();
let op = signed_transfer(&fx, 250);
let sender_before = fx.state.get(&fx.sender_id).unwrap().balance;
let receiver_before = fx.state.get(&fx.receiver_id).unwrap().balance;
let mut state = fx.state;
apply_transfer(&op, &mut state, TEST_WINDOW);
let sender_after = state.get(&fx.sender_id).unwrap().balance;
let receiver_after = state.get(&fx.receiver_id).unwrap().balance;
assert_eq!(sender_after, sender_before - 250);
assert_eq!(receiver_after, receiver_before + 250);
}
#[test]
fn apply_transfer_sum_delta_balance_is_zero() {
// spec: Anti-inflation — Σ delta_balance == 0 для Transfer
let fx = setup_transfer();
let op = signed_transfer(&fx, 777);
let sender_before = fx.state.get(&fx.sender_id).unwrap().balance;
let receiver_before = fx.state.get(&fx.receiver_id).unwrap().balance;
let mut state = fx.state;
apply_transfer(&op, &mut state, TEST_WINDOW);
let sender_after = state.get(&fx.sender_id).unwrap().balance;
let receiver_after = state.get(&fx.receiver_id).unwrap().balance;
let delta_sender = sender_after as i128 - sender_before as i128;
let delta_receiver = receiver_after as i128 - receiver_before as i128;
assert_eq!(delta_sender + delta_receiver, 0);
}
#[test]
fn apply_transfer_updates_sender_frontier_and_chain_length() {
let fx = setup_transfer();
let op = signed_transfer(&fx, 100);
let expected_frontier = op_hash(&Operation::Transfer(op.clone()));
let sender_chain_before = fx.state.get(&fx.sender_id).unwrap().account_chain_length;
let mut state = fx.state;
apply_transfer(&op, &mut state, TEST_WINDOW);
let sender = state.get(&fx.sender_id).unwrap();
assert_eq!(sender.frontier_hash, expected_frontier);
assert_eq!(sender.account_chain_length, sender_chain_before + 1);
assert_eq!(sender.last_op_window, TEST_WINDOW_U32);
}
#[test]
fn apply_transfer_receiver_frontier_and_chain_length_unchanged() {
// spec dep rule: receiver Transfer не получает chain_length++ и frontier update
let fx = setup_transfer();
let op = signed_transfer(&fx, 100);
let receiver_before = fx.state.get(&fx.receiver_id).unwrap().clone();
let mut state = fx.state;
apply_transfer(&op, &mut state, TEST_WINDOW);
let receiver_after = state.get(&fx.receiver_id).unwrap();
assert_eq!(receiver_after.frontier_hash, receiver_before.frontier_hash);
assert_eq!(
receiver_after.account_chain_length,
receiver_before.account_chain_length
);
assert_eq!(
receiver_after.last_op_window,
receiver_before.last_op_window
);
assert_eq!(receiver_after.op_height, receiver_before.op_height);
}
#[test]
fn apply_change_key_updates_pubkey_and_suite_id() {
let (old_pk, _old_sk) = keypair();
let (new_pk, _new_sk) = keypair();
let frontier = [0x33u8; 32];
let record = make_account_record(old_pk.as_bytes(), MLDSA_SUITE, 0, frontier);
let sender_id = record.account_id;
let mut state = AccountTable::new();
state.insert(record);
let op = ChangeKey {
prev_hash: frontier,
sender: sender_id,
new_suite_id: MLDSA_SUITE, // пока только один suite, но поле обновляется
new_pubkey: new_pk.clone(),
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
};
apply_change_key(&op, &mut state, TEST_WINDOW);
let sender = state.get(&sender_id).unwrap();
assert_eq!(sender.current_pubkey, *new_pk.as_bytes());
assert_eq!(sender.suite_id, MLDSA_SUITE);
}
#[test]
fn apply_change_key_updates_frontier_and_chain_length() {
let (old_pk, _) = keypair();
let (new_pk, _) = keypair();
let frontier = [0x33u8; 32];
let record = make_account_record(old_pk.as_bytes(), MLDSA_SUITE, 0, frontier);
let sender_id = record.account_id;
let chain_before = record.account_chain_length;
let mut state = AccountTable::new();
state.insert(record);
let op = ChangeKey {
prev_hash: frontier,
sender: sender_id,
new_suite_id: MLDSA_SUITE,
new_pubkey: new_pk,
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
};
let expected_frontier = op_hash(&Operation::ChangeKey(op.clone()));
apply_change_key(&op, &mut state, TEST_WINDOW);
let sender = state.get(&sender_id).unwrap();
assert_eq!(sender.frontier_hash, expected_frontier);
assert_eq!(sender.account_chain_length, chain_before + 1);
assert_eq!(sender.last_op_window, TEST_WINDOW_U32);
}
#[test]
fn apply_anchor_updates_frontier_only_balance_unchanged() {
let (pk, _) = keypair();
let initial_balance = 500_000u128;
let frontier = [0x44u8; 32];
let record = make_account_record(pk.as_bytes(), MLDSA_SUITE, initial_balance, frontier);
let sender_id = record.account_id;
let chain_before = record.account_chain_length;
let mut state = AccountTable::new();
state.insert(record);
let op = Anchor {
prev_hash: frontier,
sender: sender_id,
app_id: [0x88u8; 32],
data_hash: [0x99u8; 32],
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
};
let expected_frontier = op_hash(&Operation::Anchor(op.clone()));
apply_anchor(&op, &mut state, TEST_WINDOW);
let sender = state.get(&sender_id).unwrap();
assert_eq!(sender.balance, initial_balance); // balance UNCHANGED
assert_eq!(sender.frontier_hash, expected_frontier);
assert_eq!(sender.account_chain_length, chain_before + 1);
assert_eq!(sender.last_op_window, TEST_WINDOW_U32);
}
#[test]
fn apply_anchor_does_not_store_data_hash() {
// spec: data_hash живёт в proposal chain, не в AccountTable
// => применение Anchor не создаёт никаких новых полей в записи,
// data_hash никак не отражается на state (только frontier меняется через op_hash)
let (pk, _) = keypair();
let frontier = [0x44u8; 32];
let record = make_account_record(pk.as_bytes(), MLDSA_SUITE, 0, frontier);
let sender_id = record.account_id;
let mut state = AccountTable::new();
state.insert(record.clone());
let op = Anchor {
prev_hash: frontier,
sender: sender_id,
app_id: [0x88u8; 32],
data_hash: [0x99u8; 32],
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
};
apply_anchor(&op, &mut state, TEST_WINDOW);
// Все поля кроме frontier/chain_length/op_height/last_op_window идентичны
let after = state.get(&sender_id).unwrap();
assert_eq!(after.balance, record.balance);
assert_eq!(after.current_pubkey, record.current_pubkey);
assert_eq!(after.suite_id, record.suite_id);
assert_eq!(after.creation_window, record.creation_window);
assert_eq!(after.account_id, record.account_id);
}
#[test]
fn apply_dispatcher_delegates_transfer() {
let fx = setup_transfer();
let op = signed_transfer(&fx, 100);
let mut via_direct = fx.state.clone();
apply_transfer(&op, &mut via_direct, TEST_WINDOW);
let mut via_dispatch = fx.state;
apply(&Operation::Transfer(op), &mut via_dispatch, TEST_WINDOW);
assert_eq!(via_direct.root(), via_dispatch.root());
}
#[test]
fn apply_transfer_activation_sender_op_height_increments() {
let (sender_pk, _) = keypair();
let sender_id = derive_account_id(MLDSA_SUITE, sender_pk.as_bytes());
let (receiver_pk, _) = keypair();
let receiver_id = derive_account_id(MLDSA_SUITE, receiver_pk.as_bytes());
let mut state = AccountTable::new();
let mut sender_rec =
make_account_record(sender_pk.as_bytes(), MLDSA_SUITE, 1_000_000, [0u8; 32]);
sender_rec.op_height = 5;
state.insert(sender_rec);
let op = TransferActivation {
prev_hash: [0u8; 32],
sender: sender_id,
receiver: receiver_id,
suite_id: MLDSA_SUITE,
receiver_pubkey: receiver_pk,
amount: 100,
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
};
apply_transfer_activation(&op, &mut state, TEST_WINDOW);
let sender = state.get(&sender_id).unwrap();
assert_eq!(sender.op_height, 6);
}
#[test]
fn apply_state_root_changes_on_transfer() {
// State root deterministic — после apply_transfer root изменился
let fx = setup_transfer();
let op = signed_transfer(&fx, 100);
let root_before = fx.state.root();
let mut state = fx.state;
apply_transfer(&op, &mut state, TEST_WINDOW);
let root_after = state.root();
assert_ne!(root_before, root_after);
}
#[test]
fn apply_transfer_op_height_increments_on_sender_only() {
let fx = setup_transfer();
let op = signed_transfer(&fx, 100);
let sender_op_height_before = fx.state.get(&fx.sender_id).unwrap().op_height;
let receiver_op_height_before = fx.state.get(&fx.receiver_id).unwrap().op_height;
let mut state = fx.state;
apply_transfer(&op, &mut state, TEST_WINDOW);
assert_eq!(
state.get(&fx.sender_id).unwrap().op_height,
sender_op_height_before + 1
);
assert_eq!(
state.get(&fx.receiver_id).unwrap().op_height,
receiver_op_height_before // UNCHANGED per dep rule
);
}
// ================== Phase D: emission (const EMISSION_moneta = 13 Ɉ) ==================
const EMISSION: u128 = 13_000_000_000;
#[test]
fn reward_moneta_is_const() {
let p = mt_genesis::genesis_params();
assert_eq!(reward_moneta(p), EMISSION);
}
#[test]
fn reward_moneta_independent_of_window() {
let p = mt_genesis::genesis_params();
let r0 = reward_moneta(p);
let r1 = reward_moneta(p);
assert_eq!(r0, r1);
assert_eq!(r0, EMISSION);
}
#[test]
fn supply_moneta_window_zero() {
let p = mt_genesis::genesis_params();
assert_eq!(supply_moneta(0, p), EMISSION);
}
#[test]
fn supply_moneta_grows_linearly() {
let p = mt_genesis::genesis_params();
assert_eq!(supply_moneta(0, p), EMISSION);
assert_eq!(supply_moneta(1, p), EMISSION * 2);
assert_eq!(supply_moneta(100, p), EMISSION * 101);
assert_eq!(supply_moneta(1_000_000, p), EMISSION * 1_000_001);
}
#[test]
fn supply_moneta_closed_form_matches_per_window_sum() {
let p = mt_genesis::genesis_params();
for &w in &[0u64, 1, 10, 100, 1000, 524_160] {
let mut expected: u128 = 0;
for _ in 0..=w {
expected += reward_moneta(p);
}
assert_eq!(supply_moneta(w, p), expected, "mismatch at W={w}");
}
}
// ================== Phase E: apply_proposal ==================
fn make_node_record(node_id_byte: u8, operator: AccountId) -> NodeRecord {
NodeRecord {
node_id: [node_id_byte; 32],
node_pubkey: [0u8; PUBLIC_KEY_SIZE],
suite_id: MLDSA_SUITE,
operator_account_id: operator,
start_window: 0,
chain_length: 100,
chain_length_snapshot: 0,
chain_length_checkpoints: [50, 60, 70, 80, 90, 100],
last_confirmation_window: 0,
}
}
#[test]
fn settle_window_sorts_by_op_hash_lex_asc() {
// Три TransferActivation с разными receiver_pubkey → разные op_hash.
// settle_window должен отсортировать ops по op_hash и применить детерминированно.
let fx = setup_transfer();
let mut state1 = fx.state.clone();
let mut state2 = fx.state.clone();
let ops: Vec<Operation> = (0..3)
.map(|i| {
let (receiver_pk, _) = keypair();
let receiver_id = derive_account_id(MLDSA_SUITE, receiver_pk.as_bytes());
Operation::TransferActivation(TransferActivation {
prev_hash: fx.state.get(&fx.sender_id).unwrap().frontier_hash,
sender: fx.sender_id,
receiver: receiver_id,
suite_id: MLDSA_SUITE,
receiver_pubkey: receiver_pk,
amount: 1,
signature: Signature::from_array([i; SIGNATURE_SIZE]),
})
})
.collect();
let reversed: Vec<Operation> = ops.iter().rev().cloned().collect();
settle_window(&mut state1, &ops, 10);
settle_window(&mut state2, &reversed, 10);
assert_eq!(state1.root(), state2.root());
}
#[test]
fn settle_window_empty_ops_no_change() {
let fx = setup_transfer();
let root_before = fx.state.root();
let mut state = fx.state;
settle_window(&mut state, &[], 10);
assert_eq!(state.root(), root_before);
}
// spec Sovereignty Ladder: apply_proposal_emission_credits_account_winner удалён
// как obsolete. Лотерея single-class, winner всегда узел;
// account не может быть winner_id напрямую.
#[test]
fn apply_proposal_emission_credits_node_operator() {
let fx = setup_transfer();
let mut account_table = fx.state;
let mut node_table = NodeTable::new();
let candidate_pool = CandidatePool::new();
let operator = fx.receiver_id;
let node = make_node_record(0xAA, operator);
let node_id = node.node_id;
node_table.insert(node);
let balance_before = account_table.get(&operator).unwrap().balance;
let input = ProposalSettle {
window_w: 10,
winner_id: node_id,
cemented_confirmers: vec![],
};
let p = mt_genesis::genesis_params();
let _root = apply_proposal(
&mut account_table,
&mut node_table,
&candidate_pool,
&input,
p,
);
// reward = const EMISSION_moneta = 13 Ɉ.
let expected_reward = reward_moneta(p);
assert_eq!(
account_table.get(&operator).unwrap().balance,
balance_before + expected_reward
);
}
#[test]
fn apply_proposal_emission_no_op_at_window_zero() {
let fx = setup_transfer();
let mut account_table = fx.state.clone();
let mut node_table = NodeTable::new();
let candidate_pool = CandidatePool::new();
let balance_before = account_table.get(&fx.receiver_id).unwrap().balance;
// W=0: apply_emission early-returns до lookup; winner_id значение не важно
let input = ProposalSettle {
window_w: 0,
winner_id: [0u8; 32],
cemented_confirmers: vec![],
};
let p = mt_genesis::genesis_params();
apply_proposal(
&mut account_table,
&mut node_table,
&candidate_pool,
&input,
p,
);
// W=0: no W-1, emission skipped
assert_eq!(
account_table.get(&fx.receiver_id).unwrap().balance,
balance_before
);
}
#[test]
fn apply_proposal_chain_length_increment() {
let fx = setup_transfer();
let mut account_table = fx.state;
let mut node_table = NodeTable::new();
let candidate_pool = CandidatePool::new();
let node_a = make_node_record(0x01, fx.sender_id);
let node_b = make_node_record(0x02, fx.sender_id);
let id_a = node_a.node_id;
let id_b = node_b.node_id;
let chain_before_a = node_a.chain_length;
let chain_before_b = node_b.chain_length;
node_table.insert(node_a);
node_table.insert(node_b);
let input = ProposalSettle {
window_w: 15,
winner_id: id_a,
cemented_confirmers: vec![id_a, id_b],
};
let p = mt_genesis::genesis_params();
apply_proposal(
&mut account_table,
&mut node_table,
&candidate_pool,
&input,
p,
);
let after_a = node_table.get(&id_a).unwrap();
let after_b = node_table.get(&id_b).unwrap();
assert_eq!(after_a.chain_length, chain_before_a + 1);
assert_eq!(after_a.last_confirmation_window, 15);
assert_eq!(after_b.chain_length, chain_before_b + 1);
assert_eq!(after_b.last_confirmation_window, 15);
}
#[test]
fn apply_proposal_chain_length_ignores_unknown_confirmer() {
// Нода-id не в NodeTable — игнорируется (protocol bug защита)
let fx = setup_transfer();
let mut account_table = fx.state;
let mut node_table = NodeTable::new();
let candidate_pool = CandidatePool::new();
let node = make_node_record(0x99, fx.sender_id);
let node_id = node.node_id;
node_table.insert(node);
let input = ProposalSettle {
window_w: 5,
winner_id: node_id,
cemented_confirmers: vec![[0xFFu8; 32]], // unknown
};
let p = mt_genesis::genesis_params();
apply_proposal(
&mut account_table,
&mut node_table,
&candidate_pool,
&input,
p,
);
// Нет panic, node_table содержит только одну вставленную ноду (unknown confirmer проигнорирован)
assert_eq!(node_table.len(), 1);
}
#[test]
fn apply_proposal_checkpoint_rotation_on_tau2_boundary() {
let fx = setup_transfer();
let mut account_table = fx.state;
let mut node_table = NodeTable::new();
let candidate_pool = CandidatePool::new();
let mut node = make_node_record(0x11, fx.sender_id);
node.chain_length = 150;
node.chain_length_checkpoints = [50, 60, 70, 80, 90, 100];
let node_id = node.node_id;
node_table.insert(node);
let p = mt_genesis::genesis_params();
let input = ProposalSettle {
window_w: p.tau2_windows, // τ₂ boundary
winner_id: node_id,
cemented_confirmers: vec![],
};
apply_proposal(
&mut account_table,
&mut node_table,
&candidate_pool,
&input,
p,
);
let rotated = node_table.get(&node_id).unwrap();
// Shift: [50,60,70,80,90,100] → [60,70,80,90,100,150]
assert_eq!(rotated.chain_length_checkpoints, [60, 70, 80, 90, 100, 150]);
// snapshot = chain_length - oldest (после rotation) = 150 - 60 = 90
assert_eq!(rotated.chain_length_snapshot, 90);
}
#[test]
fn apply_proposal_checkpoint_rotation_no_op_off_boundary() {
let fx = setup_transfer();
let mut account_table = fx.state;
let mut node_table = NodeTable::new();
let candidate_pool = CandidatePool::new();
let node = make_node_record(0x11, fx.sender_id);
let node_id = node.node_id;
let checkpoints_before = node.chain_length_checkpoints;
node_table.insert(node);
let p = mt_genesis::genesis_params();
let input = ProposalSettle {
window_w: p.tau2_windows + 1, // НЕ boundary
winner_id: node_id,
cemented_confirmers: vec![],
};
apply_proposal(
&mut account_table,
&mut node_table,
&candidate_pool,
&input,
p,
);
let after = node_table.get(&node_id).unwrap();
assert_eq!(after.chain_length_checkpoints, checkpoints_before);
}
#[test]
fn apply_proposal_checkpoint_rotation_skipped_at_window_zero() {
let fx = setup_transfer();
let mut account_table = fx.state;
let mut node_table = NodeTable::new();
let candidate_pool = CandidatePool::new();
let node = make_node_record(0x22, fx.sender_id);
let node_id = node.node_id;
let before = node.chain_length_checkpoints;
node_table.insert(node);
let p = mt_genesis::genesis_params();
// W=0: apply_emission early-returns; winner_id значение не важно
let input = ProposalSettle {
window_w: 0,
winner_id: [0u8; 32],
cemented_confirmers: vec![],
};
apply_proposal(
&mut account_table,
&mut node_table,
&candidate_pool,
&input,
p,
);
let after = node_table.get(&node_id).unwrap();
assert_eq!(after.chain_length_checkpoints, before);
}
#[test]
fn apply_proposal_state_root_deterministic() {
let fx = setup_transfer();
let p = mt_genesis::genesis_params();
let run = |initial_state: AccountTable| -> Hash32 {
let mut acc = initial_state;
let mut nodes = NodeTable::new();
let candidates = CandidatePool::new();
let node = make_node_record(0x33, fx.sender_id);
let node_id = node.node_id;
nodes.insert(node);
let input = ProposalSettle {
window_w: 20,
winner_id: node_id,
cemented_confirmers: vec![node_id],
};
apply_proposal(&mut acc, &mut nodes, &candidates, &input, p)
};
let r1 = run(fx.state.clone());
let r2 = run(fx.state);
assert_eq!(r1, r2);
}
#[test]
fn apply_proposal_state_root_matches_manual_composition() {
let fx = setup_transfer();
let mut account_table = fx.state;
let mut node_table = NodeTable::new();
let candidate_pool = CandidatePool::new();
let p = mt_genesis::genesis_params();
let node = make_node_record(0x77, fx.sender_id);
let node_id = node.node_id;
node_table.insert(node);
let input = ProposalSettle {
window_w: 7,
winner_id: node_id,
cemented_confirmers: vec![],
};
let returned_root = apply_proposal(
&mut account_table,
&mut node_table,
&candidate_pool,
&input,
p,
);
let manual = compute_state_root(
&node_table.root(),
&candidate_pool.root(),
&account_table.root(),
);
assert_eq!(returned_root, manual);
}
#[test]
fn apply_proposal_emission_changes_state_root() {
let fx = setup_transfer();
let mut account_table = fx.state;
let mut node_table = NodeTable::new();
let candidate_pool = CandidatePool::new();
let p = mt_genesis::genesis_params();
let node = make_node_record(0x88, fx.sender_id);
let node_id = node.node_id;
node_table.insert(node);
let root_before = compute_state_root(
&node_table.root(),
&candidate_pool.root(),
&account_table.root(),
);
let input = ProposalSettle {
window_w: 3,
winner_id: node_id,
cemented_confirmers: vec![],
};
let root_after = apply_proposal(
&mut account_table,
&mut node_table,
&candidate_pool,
&input,
p,
);
assert_ne!(root_before, root_after);
}
#[test]
fn apply_proposal_only_cemented_confirmers_updated() {
// Узел НЕ в confirmers list не получает chain_length++
let fx = setup_transfer();
let mut account_table = fx.state;
let mut node_table = NodeTable::new();
let candidate_pool = CandidatePool::new();
let a = make_node_record(0x01, fx.sender_id);
let b = make_node_record(0x02, fx.sender_id);
let id_a = a.node_id;
let id_b = b.node_id;
let chain_b_before = b.chain_length;
node_table.insert(a);
node_table.insert(b);
let input = ProposalSettle {
window_w: 5,
winner_id: id_a,
cemented_confirmers: vec![id_a], // только A, не B
};
let p = mt_genesis::genesis_params();
apply_proposal(
&mut account_table,
&mut node_table,
&candidate_pool,
&input,
p,
);
assert_eq!(
node_table.get(&id_b).unwrap().chain_length,
chain_b_before // НЕ изменился
);
}
#[test]
fn proposal_settle_struct_fields_accessible() {
// Sanity check — struct публичный + все fields публичные
let s = ProposalSettle {
window_w: 42,
winner_id: [0xAB; 32],
cemented_confirmers: vec![[0x01; 32]],
};
assert_eq!(s.window_w, 42);
}
// Anti-regression M3-A-1: apply_chain_length_increment use checked_add
// (consistency с другими apply_*). u64::MAX overflow → descriptive panic.
#[test]
#[should_panic(expected = "apply_chain_length_increment: chain_length overflow at u64::MAX")]
fn apply_chain_length_panics_on_overflow() {
let fx = setup_transfer();
let mut account_table = fx.state;
let mut node_table = NodeTable::new();
let candidate_pool = CandidatePool::new();
let mut node = make_node_record(0x11, fx.sender_id);
node.chain_length = u64::MAX; // protocol invariant breach trigger
let node_id = node.node_id;
node_table.insert(node);
let input = ProposalSettle {
window_w: 5,
winner_id: node_id,
cemented_confirmers: vec![node_id],
};
let p = mt_genesis::genesis_params();
apply_proposal(
&mut account_table,
&mut node_table,
&candidate_pool,
&input,
p,
);
}
// Anti-regression M3-A-2: apply_checkpoint_rotation use checked_sub
// (defense-in-depth). Corrupted state checkpoints[0] > chain_length →
// descriptive panic, не silent u64 wrap до huge chain_length_snapshot.
#[test]
#[should_panic(expected = "apply_checkpoint_rotation: invariant breach")]
fn apply_checkpoint_rotation_panics_on_underflow() {
let fx = setup_transfer();
let mut account_table = fx.state;
let mut node_table = NodeTable::new();
let candidate_pool = CandidatePool::new();
let mut node = make_node_record(0x11, fx.sender_id);
node.chain_length = 100;
// После rotation [80,90,100,110,120,130] → checkpoints[0] = 90 (старый
// index 1). Это > chain_length=100? Нет, 90<100. Нужно так чтобы
// старый index 1 > chain_length: ставим [_, 200, _, _, _, _].
node.chain_length_checkpoints = [50, 200, 70, 80, 90, 100];
let node_id = node.node_id;
node_table.insert(node);
let p = mt_genesis::genesis_params();
let input = ProposalSettle {
window_w: p.tau2_windows, // τ₂ boundary triggers rotation
winner_id: node_id,
cemented_confirmers: vec![],
};
apply_proposal(
&mut account_table,
&mut node_table,
&candidate_pool,
&input,
p,
);
}
// ================== Phase F: Genesis state ==================
#[test]
fn genesis_state_has_one_account_one_node_empty_candidates() {
let p = mt_genesis::genesis_params();
let g = build_genesis_state(p);
assert_eq!(g.account_table.len(), 1);
assert_eq!(g.node_table.len(), 1);
assert_eq!(g.candidate_pool.len(), 0);
}
#[test]
fn genesis_account_is_node_operator_with_zero_balance() {
let p = mt_genesis::genesis_params();
let g = build_genesis_state(p);
let account_id = derive_account_id(GENESIS_SUITE_ID, &p.bootstrap_account_pubkey);
let acct = g.account_table.get(&account_id).expect("genesis account");
assert_eq!(acct.balance, 0);
assert!(acct.is_node_operator);
assert_eq!(acct.creation_window, 0);
assert_eq!(acct.op_height, 0);
assert_eq!(acct.account_chain_length, 0);
assert_eq!(acct.suite_id, GENESIS_SUITE_ID);
}
#[test]
fn genesis_account_frontier_hash_spec_formula() {
let p = mt_genesis::genesis_params();
let g = build_genesis_state(p);
let account_id = derive_account_id(GENESIS_SUITE_ID, &p.bootstrap_account_pubkey);
let acct = g.account_table.get(&account_id).unwrap();
// spec: frontier_hash = SHA-256("mt-genesis" || account_id)
let expected = hash(domain::GENESIS, &[&account_id]);
assert_eq!(acct.frontier_hash, expected);
}
#[test]
fn genesis_account_id_derived_from_bootstrap_pubkey() {
let p = mt_genesis::genesis_params();
let g = build_genesis_state(p);
let expected_id = derive_account_id(GENESIS_SUITE_ID, &p.bootstrap_account_pubkey);
assert!(g.account_table.contains(&expected_id));
}
#[test]
fn genesis_node_chain_length_is_one() {
// spec invariant: chain_length ≥ 1 для любого узла
let p = mt_genesis::genesis_params();
let g = build_genesis_state(p);
let node_id = derive_node_id(&p.bootstrap_node_pubkey);
let node = g.node_table.get(&node_id).expect("genesis node");
assert_eq!(node.chain_length, 1);
}
#[test]
fn genesis_node_operator_matches_genesis_account() {
let p = mt_genesis::genesis_params();
let g = build_genesis_state(p);
let node_id = derive_node_id(&p.bootstrap_node_pubkey);
let account_id = derive_account_id(GENESIS_SUITE_ID, &p.bootstrap_account_pubkey);
let node = g.node_table.get(&node_id).unwrap();
assert_eq!(node.operator_account_id, account_id);
assert_eq!(node.start_window, 0);
assert_eq!(node.last_confirmation_window, 0);
assert_eq!(node.chain_length_snapshot, 0);
assert_eq!(node.chain_length_checkpoints, [0u64; 6]);
}
#[test]
fn genesis_node_id_derived_from_bootstrap_pubkey() {
let p = mt_genesis::genesis_params();
let g = build_genesis_state(p);
let expected = derive_node_id(&p.bootstrap_node_pubkey);
assert!(g.node_table.contains(&expected));
}
#[test]
fn genesis_candidate_pool_is_empty_and_root_matches_fresh_empty_pool() {
let p = mt_genesis::genesis_params();
let g = build_genesis_state(p);
assert!(g.candidate_pool.is_empty());
// spec, "Вход и регистрация → Genesis State":
// genesis_candidate_root = empty_internal(256)
// Sparse Merkle root пустого дерева на TREE_DEPTH=256 — каноническое
// значение, consistent с rest of state composition (account_root,
// node_root тоже через empty_internal). Binding check: genesis pool
// root == empty_internal(256) byte-exact + determinism vs fresh pool.
let fresh = CandidatePool::new();
assert_eq!(g.candidate_pool.root(), fresh.root());
assert_eq!(g.candidate_pool.root(), mt_merkle::empty_internal(256));
}
#[test]
fn build_genesis_state_is_deterministic() {
let p = mt_genesis::genesis_params();
let g1 = build_genesis_state(p);
let g2 = build_genesis_state(p);
assert_eq!(genesis_state_root(&g1), genesis_state_root(&g2));
assert_eq!(g1.account_table.root(), g2.account_table.root());
assert_eq!(g1.node_table.root(), g2.node_table.root());
}
#[test]
fn genesis_state_root_matches_manual_composition() {
let p = mt_genesis::genesis_params();
let g = build_genesis_state(p);
let expected = compute_state_root(
&g.node_table.root(),
&g.candidate_pool.root(),
&g.account_table.root(),
);
assert_eq!(genesis_state_root(&g), expected);
}
#[test]
fn genesis_suite_id_is_mldsa_65() {
// spec: suite_id = 0x0001 (ML-DSA-65)
assert_eq!(GENESIS_SUITE_ID, 0x0001);
assert_eq!(GENESIS_SUITE_ID, MLDSA_SUITE);
}
#[test]
fn genesis_supply_is_zero() {
// spec: "Genesis State (до первого окна, supply = 0)"
let p = mt_genesis::genesis_params();
let g = build_genesis_state(p);
let total: u128 = g.account_table.iter().map(|r| r.balance).sum();
assert_eq!(total, 0);
}
}