2557 lines
92 KiB
Rust
2557 lines
92 KiB
Rust
// 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, ¶ms.bootstrap_account_pubkey);
|
||
let node_id = derive_node_id(¶ms.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);
|
||
}
|
||
}
|