Files
nomos-node/ledger/src/lib.rs
T

1883 lines
67 KiB
Rust

mod config;
// The ledger is split into two modules:
// - `cryptarchia`: the base functionalities needed by the Cryptarchia consensus
// algorithm, including a minimal UTxO model.
// - `mantle_ops`: our extensions in the form of Mantle operations, e.g. SDP.
pub mod cryptarchia;
pub mod mantle;
use std::hash::Hash;
pub use config::Config;
use cryptarchia::LedgerState as CryptarchiaLedger;
pub use cryptarchia::{EpochState, UtxoTree};
use lb_core::{
block::BlockNumber,
events::{Events, HeaderEvent, TxEvent},
mantle::{
NoteId, Op, Utxo, Value, VerificationError,
gas::{Gas, GasConstants, GasCost, GasOverflow},
ledger::Operation as _,
ops::{
channel::{
channel_transfer::ChannelTransferExecutionContext,
deposit::DepositExecutionContext, withdraw::WithdrawExecutionContext,
},
leader_claim::LeaderClaimExecutionContext,
},
traits::{GenesisTx, MantleTxWithProofs, PreverifiedMantleTx},
transactions::{GasPrices, MantleTxGasContext, hash::TxHash, mantle_tx::MantleTxContext},
},
proofs::leader_proof,
};
use lb_cryptarchia_engine::Slot;
use lb_groth16::{AdditiveGroup as _, Fr};
use mantle::LedgerState as MantleLedger;
use rpds::HashTrieMapSync;
use thiserror::Error;
use crate::mantle::helpers::MantleOperationVerificationHelper;
const WINDOW_SIZE: usize = 120;
/// Denominator of 1/(`I_max` * `D1_target` * `Delta_t` * `T`)
/// That correspond to `BLOCK_PER_YEAR` / (`MAX_INFLATION` * `KPI_FEE_TARGET` *
/// `WINDOW_SIZE`)
const A_SCALE: u128 = 120_000_000;
/// Numerator of 1/(`I_max` * `D1_target` * `Delta_t` * `T`)
/// That correspond to `BLOCK_PER_YEAR` / (`MAX_INFLATION` * `KPI_FEE_TARGET` *
/// `WINDOW_SIZE`)
const FEE_AVG_NUM: u128 = 10_512;
/// Numerator of `I_max` * `S_TGE` * `DELTA_t` / `f`
/// It corresponds to `MAX_INFLATION` * `TOKEN_GENESIS` * `BLOCK_PER_BLOCK` /
/// `BLOCK_PER_YEAR`
const INFLATION_NUMERATOR: u128 = 62_500;
/// Numerator of `I_max` * `S_TGE` * `DELTA_t` / `f`
/// It corresponds to `MAX_INFLATION` * `TOKEN_GENESIS` * `BLOCK_PER_BLOCK` /
/// `BLOCK_PER_YEAR`
const INFLATION_DENOMINATOR: u128 = 657;
const STAKE_TARGET: u128 = 3_000_000_000;
// That correspond to 40% of the block rewards for leaders
const LEADER_REWARD_SHARE_NUMERATOR: u128 = 4;
const LEADER_REWARD_SHARE_DENOMINATOR: u128 = 10;
// That correspond to 60% of the block rewards for blend nodes
const BLEND_REWARD_SHARE_NUMERATOR: u128 = 6;
const BLEND_REWARD_SHARE_DENOMINATOR: u128 = 10;
const EXECUTION_GAS_LIMIT: Gas = Gas::new(3_193_360);
// While individual notes are constrained to be `u64`, intermediate calculations
// may overflow, so we use `i128` to avoid that and to easily represent negative
// balances which may arise in special circumstances (e.g. rewards calculation).
pub type Balance = i128;
#[derive(Clone, Debug, Error, Eq, PartialEq)]
pub enum LedgerError<Id> {
#[error("Invalid block slot {block:?} for parent slot {parent:?}")]
InvalidSlot { parent: Slot, block: Slot },
#[error("Parent block could not be found: {0:?}")]
ParentNotFound(Id),
#[error("Invalid leader proof")]
InvalidProof,
#[error("Insufficient balance")]
InsufficientBalance,
#[error("Applying this transaction would cause a balance overflow")]
BalanceOverflow,
#[error("Unbalanced transaction, balance does not match fees")]
UnbalancedTransaction,
#[error(transparent)]
GasOverflow(#[from] GasOverflow),
#[error("Mantle error: {0}")]
Mantle(#[from] mantle::Error),
#[error("Inputs error: {0}")]
Inputs(#[from] lb_core::mantle::ledger::InputsError),
#[error("Mantle error: {0}")]
Outputs(#[from] lb_core::mantle::ledger::OutputsError),
#[error("Input note in genesis block: {0:?}")]
InputInGenesis(NoteId),
#[error("The first Transfer Operation is missing in genesis tx")]
MissingTransferGenesis(),
#[error("Fees don't cover the minimal execution base fee cost")]
InsufficientExecutionFee,
#[error("The execution gas of the block ({gas:?}) exceeds the maximum limit ({limit:?}")]
TooMuchExecutionGas { gas: Gas, limit: Gas },
#[error("Storage fees aren't equal to the storage fee of the current epoch")]
InvalidStoragePrice,
#[error("Verification error: {0}")]
VerificationError(#[from] VerificationError),
}
#[derive(Clone, Debug, PartialEq)]
pub struct Ledger<Id: Eq + Hash> {
states: HashTrieMapSync<Id, LedgerState>,
config: Config,
}
impl<Id> Ledger<Id>
where
Id: Eq + Hash + Copy,
{
pub fn new(id: Id, state: LedgerState, config: Config) -> Self {
Self {
states: HashTrieMapSync::new_sync().insert(id, state),
config,
}
}
/// Prepare to add a new [`LedgerState`] by applying the given proof and
/// transactions on top of the parent state.
///
/// On success, a new [`LedgerState`] is returned, which can then be
/// committed by calling [`Self::commit_update`].
pub fn prepare_update<'tx, Tx, LeaderProof, Constants>(
&self,
id: Id,
parent_id: Id,
slot: Slot,
proof: &LeaderProof,
txs: impl Iterator<Item = &'tx Tx>,
) -> Result<(Id, LedgerState, Events), LedgerError<Id>>
where
Tx: PreverifiedMantleTx<Context = GasPrices> + 'tx,
LeaderProof: leader_proof::LeaderProof,
Constants: GasConstants,
{
let parent_state = self
.states
.get(&parent_id)
.ok_or(LedgerError::ParentNotFound(parent_id))?;
let (new_state, events) = parent_state.clone().try_update::<_, _, _, Constants>(
slot,
proof,
txs,
&self.config,
)?;
Ok((id, new_state, events))
}
/// Commits a new [`LedgerState`] created by [`Self::prepare_update`].
pub fn commit_update(&mut self, id: Id, state: LedgerState) {
self.states.insert_mut(id, state);
}
pub fn state(&self, id: &Id) -> Option<&LedgerState> {
self.states.get(id)
}
#[must_use]
pub const fn config(&self) -> &Config {
&self.config
}
/// Removes the state stored for the given block id.
///
/// This function must be called only when the states being pruned won't be
/// needed for any subsequent proof.
///
/// ## Arguments
///
/// The block ID to prune the state for.
///
/// ## Returns
///
/// `true` if the state was successfully removed, `false` otherwise.
pub fn prune_state_at(&mut self, block: &Id) -> bool {
self.states.remove_mut(block)
}
}
/// A ledger state
///
/// NOTE: Most collection fields in this struct should use `rpds`
/// since we keep a copy of this state for each block.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct LedgerState {
block_number: BlockNumber,
cryptarchia_ledger: CryptarchiaLedger,
mantle_ledger: MantleLedger,
}
impl LedgerState {
fn try_update<'tx, Tx, LeaderProof, Id, Constants>(
self,
slot: Slot,
proof: &LeaderProof,
txs: impl Iterator<Item = &'tx Tx>,
config: &Config,
) -> Result<(Self, Events), LedgerError<Id>>
where
Tx: PreverifiedMantleTx<Context = GasPrices> + 'tx,
LeaderProof: leader_proof::LeaderProof,
Constants: GasConstants,
{
let (state, header_events) = self.try_apply_header(slot, proof, config)?;
let (state, tx_events) = state.try_apply_contents::<_, _, Constants>(config, txs)?;
let events = header_events
.into_iter()
.map(Into::into)
.chain(tx_events.into_iter().map(Into::into))
.collect::<Events>();
Ok((state, events))
}
/// Apply header-related changed to the ledger state. These include
/// leadership and in general any changes that not related to
/// transactions that should be applied before that.
///
/// Returns any [`HeaderEvent`]s emitted while processing the header.
pub fn try_apply_header<LeaderProof, Id>(
self,
slot: Slot,
proof: &LeaderProof,
config: &Config,
) -> Result<(Self, Vec<HeaderEvent>), LedgerError<Id>>
where
LeaderProof: leader_proof::LeaderProof,
{
let last_epoch_state = self.cryptarchia_ledger.epoch_state().clone();
let mut cryptarchia_ledger = self
.cryptarchia_ledger
.try_apply_header::<LeaderProof, Id>(
slot,
proof,
// TODO: threading SDP here because EpochState is currently embedded in
// CryptarchiaLedger.
// In the future, we will pull EpochState up into LedgerState.
&self.mantle_ledger.sdp,
config,
)?;
let (mantle_ledger, effect) = self.mantle_ledger.try_apply_header(
&last_epoch_state,
cryptarchia_ledger.epoch_state(),
*proof.voucher_cm(),
config,
)?;
// Insert reward UTXOs into the cryptarchia ledger
for utxo in effect.reward_utxos {
cryptarchia_ledger.utxos = cryptarchia_ledger.utxos.insert(utxo.id(), utxo).0;
}
Ok((
Self {
block_number: self
.block_number
.checked_add(1)
.expect("Logos blockchain lived long and prospered"),
cryptarchia_ledger,
mantle_ledger,
},
effect.events,
))
}
#[must_use]
pub const fn get_gas_prices(&self) -> GasPrices {
GasPrices {
execution_base_gas_price: *self.cryptarchia_ledger.execution_base_fee(),
storage_gas_price: *self.cryptarchia_ledger.storage_gas_price(),
}
}
/// total estimated stake and on the average of fees consumed per block over
/// the last `BLOCK_REWARD_WINDOW_SIZE` blocks. See the block rewards
/// specification: <https://www.notion.so/nomos-tech/v1-1-Block-Rewards-Specification-326261aa09df80579edddaf092057b3d>
fn compute_block_rewards(
mut self,
total_fee_burned: GasCost,
total_fee_tip: GasCost,
) -> Result<Self, GasOverflow> {
let window_index = self.block_number as usize % WINDOW_SIZE;
// First update the fee burned in the block
self.cryptarchia_ledger
.update_fee_window(window_index, total_fee_burned);
// Then compute the amount of block rewards
// compute A_t'
let sum_fees = self.cryptarchia_ledger.get_summed_fees();
let a_numerator = STAKE_TARGET
.saturating_add(FEE_AVG_NUM.saturating_mul(sum_fees))
.saturating_sub(u128::from(self.cryptarchia_ledger.epoch_state.total_stake))
.min(A_SCALE);
let reward_numerator = INFLATION_NUMERATOR * a_numerator
+ INFLATION_DENOMINATOR
* (A_SCALE - a_numerator)
* u128::from(
self.cryptarchia_ledger
.get_fee_from_index(window_index)
.into_inner(),
);
let reward_denominator = INFLATION_DENOMINATOR * A_SCALE;
// Blend gets 60% of block rewards while leaders get the 40% remaining + the
// tips. Casting as Value truncates the floating points
let blend_reward = (reward_numerator * BLEND_REWARD_SHARE_NUMERATOR
/ (reward_denominator * BLEND_REWARD_SHARE_DENOMINATOR))
as Value;
let leader_reward = GasCost::from(
(reward_numerator * LEADER_REWARD_SHARE_NUMERATOR
/ (reward_denominator * LEADER_REWARD_SHARE_DENOMINATOR)) as Value,
)
.checked_add(total_fee_tip)?;
self.mantle_ledger.leaders = self
.mantle_ledger
.leaders
.add_pending_rewards(leader_reward.into_inner());
self.mantle_ledger.sdp.add_blend_income(blend_reward);
Ok(self)
}
/// For each block received, execution base fees and average execution
/// consumption are updated based on the total execution gas consumed in the
/// block and the smoothed average consumption. This function updates the
/// `average_execution_gas` and the `execution_base_fee` stored in the
/// cryptarchia ledger. See the specification <https://www.notion.so/nomos-tech/v1-2-Execution-Market-Specification-326261aa09df8022b1cfcfe968bdb5e1>
fn update_execution_market(self, block_execution_gas_consumed: Gas) -> Self {
Self {
cryptarchia_ledger: self
.cryptarchia_ledger
.update_execution_market(block_execution_gas_consumed),
..self
}
}
/// Accumulates the storage gas consumed by the block into the current
/// epoch's counter, which drives the storage price update at the next
/// epoch rotation.
fn add_storage_gas_consumed<Id>(
self,
block_storage_gas_consumed: Gas,
) -> Result<Self, LedgerError<Id>> {
Ok(Self {
cryptarchia_ledger: self
.cryptarchia_ledger
.add_storage_gas_consumed(block_storage_gas_consumed)?,
..self
})
}
/// Apply the contents of an update to the ledger state.
pub fn try_apply_contents<'tx, Tx, Id, Constants: GasConstants>(
mut self,
config: &Config,
txs: impl Iterator<Item = &'tx Tx>,
) -> Result<(Self, Vec<TxEvent>), LedgerError<Id>>
where
Tx: PreverifiedMantleTx<Context = GasPrices> + 'tx,
{
let mut total_block_execution_gas: Gas = 0.into();
let mut total_block_storage_gas: Gas = 0.into();
let mut total_fee_burned: GasCost = 0.into();
let mut total_fee_tip: GasCost = 0.into();
let mut tx_events = Vec::new();
for tx in txs {
let balance;
let events;
(self, balance, events) = self.try_apply_tx::<_, _, Constants>(config, tx)?;
tx_events.extend(events);
let gas_prices = GasPrices {
execution_base_gas_price: *self.cryptarchia_ledger.execution_base_fee(),
storage_gas_price: *self.cryptarchia_ledger.storage_gas_price(),
};
// Check the transaction is balanced
let total_gas_cost = tx.total_gas_cost::<Constants>(&gas_prices)?;
tracing::debug!(
balance,
total_gas_cost = total_gas_cost.into_inner(),
storage_gas_price = ?self.cryptarchia_ledger.storage_gas_price(),
execution_gas_price = ?self.cryptarchia_ledger.execution_base_fee(),
"tx balance check"
);
// Check that the transaction at least pays for the base execution fee and
// storage
if balance < Balance::from(total_gas_cost.into_inner()) {
return Err(LedgerError::InsufficientBalance);
}
// Update the total of fee burned and tipped in the block
let tx_fee_burned = GasCost::calculate(
tx.execution_gas_consumption::<Constants>(&gas_prices)?,
gas_prices.execution_base_gas_price,
)?
.checked_add(tx.storage_gas_cost(&gas_prices)?)?;
let tx_fee_tip = GasCost::from(balance as Value).checked_sub(tx_fee_burned)?;
total_fee_burned = total_fee_burned.checked_add(tx_fee_burned)?;
total_fee_tip = total_fee_tip.checked_add(tx_fee_tip)?;
total_block_execution_gas = total_block_execution_gas
.checked_add(tx.execution_gas_consumption::<Constants>(&gas_prices)?)?;
total_block_storage_gas =
total_block_storage_gas.checked_add(tx.storage_gas_consumption(&gas_prices)?)?;
// Check that the block is not exceeding the Gas limit
if total_block_execution_gas > EXECUTION_GAS_LIMIT {
return Err(LedgerError::TooMuchExecutionGas {
gas: total_block_execution_gas,
limit: EXECUTION_GAS_LIMIT,
});
}
}
// Compute Block rewards and give tips
self = self.compute_block_rewards(total_fee_burned, total_fee_tip)?;
// Update Execution market state
self = self.update_execution_market(total_block_execution_gas);
// Accumulate storage gas consumed so the storage market can update the
// price at the next epoch rotation.
self = self.add_storage_gas_consumed(total_block_storage_gas)?;
Ok((self, tx_events))
}
pub fn from_utxos(utxos: impl IntoIterator<Item = Utxo>, config: &Config) -> Self {
let cryptarchia_ledger = CryptarchiaLedger::from_utxos(utxos, config, Fr::ZERO);
let mantle_ledger = MantleLedger::new(config, cryptarchia_ledger.epoch_state());
// Seed the genesis epoch-state membership snapshots from the genesis SDP
// ledger, which only exists after the mantle ledger is built.
let cryptarchia_ledger = cryptarchia_ledger.with_genesis_sdp(&mantle_ledger.sdp, config);
Self {
block_number: 0,
cryptarchia_ledger,
mantle_ledger,
}
}
pub fn from_genesis_tx<Id>(
tx: impl GenesisTx,
config: &Config,
epoch_nonce: Fr,
) -> Result<(Self, Vec<TxEvent>), LedgerError<Id>> {
let cryptarchia_ledger = CryptarchiaLedger::from_genesis_tx(&tx, config, epoch_nonce)?;
let (mantle_ledger, events) = MantleLedger::from_genesis_tx(
tx,
config,
cryptarchia_ledger.latest_utxos(),
cryptarchia_ledger.epoch_state(),
)?;
// Seed the genesis epoch-state membership snapshots from the genesis SDP
// ledger (which carries the genesis declarations applied above).
let cryptarchia_ledger = cryptarchia_ledger.with_genesis_sdp(&mantle_ledger.sdp, config);
Ok((
Self {
block_number: 0,
cryptarchia_ledger,
mantle_ledger,
},
events,
))
}
#[must_use]
pub const fn slot(&self) -> Slot {
self.cryptarchia_ledger.slot()
}
#[must_use]
pub const fn epoch_state(&self) -> &EpochState {
self.cryptarchia_ledger.epoch_state()
}
#[must_use]
pub const fn next_epoch_state(&self) -> &EpochState {
self.cryptarchia_ledger.next_epoch_state()
}
/// Computes the epoch state for a given slot.
///
/// This handles the case where epochs have been skipped (no blocks
/// produced).
///
/// Returns [`LedgerError::InvalidSlot`] if the slot is in the past before
/// the current ledger state.
pub fn epoch_state_for_slot<Id>(
&self,
slot: Slot,
config: &Config,
) -> Result<EpochState, LedgerError<Id>> {
self.cryptarchia_ledger
.epoch_state_for_slot(slot, &self.mantle_ledger.sdp, config)
}
#[must_use]
pub const fn latest_utxos(&self) -> &UtxoTree {
self.cryptarchia_ledger.latest_utxos()
}
#[must_use]
pub const fn aged_utxos(&self) -> &UtxoTree {
self.cryptarchia_ledger.aged_utxos()
}
#[must_use]
pub const fn mantle_ledger(&self) -> &MantleLedger {
&self.mantle_ledger
}
#[must_use]
pub fn tx_context(&self) -> MantleTxContext {
MantleTxContext {
gas_context: MantleTxGasContext::from_channels(
self.mantle_ledger().channels(),
self.get_gas_prices(),
),
leader_reward_amount: self.mantle_ledger().leader_reward_amount(),
}
}
#[expect(
clippy::too_many_lines,
reason = "This will be refactored in an upcoming PR."
)]
fn try_apply_op<Id, Constants: GasConstants>(
mut self,
op: &Op,
config: &Config,
tx_hash: &TxHash,
mut balance: Balance,
mut tx_events: Vec<TxEvent>,
) -> Result<(Self, Balance, Vec<TxEvent>), LedgerError<Id>> {
match op {
Op::ChannelInscribe(op) => {
let (result, events) = self
.mantle_ledger
.try_apply_channel_inscription(op, self.cryptarchia_ledger.slot)?;
self.mantle_ledger = result;
tx_events.extend(events);
}
Op::ChannelConfig(op) => {
let (result, events) = self
.mantle_ledger
.try_apply_channel_config(op, self.cryptarchia_ledger.slot)?;
self.mantle_ledger = result;
tx_events.extend(events);
}
Op::ChannelDeposit(op) => {
let channels = self.mantle_ledger.channels();
let utxos = self.cryptarchia_ledger.latest_utxos();
// Execute the Deposit
let (result, events) = op
.execute(DepositExecutionContext {
channels: channels.clone(),
utxos: utxos.clone(),
tx_hash: *tx_hash,
})
.map_err(mantle::Error::Channel)?;
self.mantle_ledger = self.mantle_ledger.update_channels(result.channels);
tx_events.extend(events);
}
Op::ChannelWithdraw(op) => {
let channels = self.mantle_ledger.channels();
let (result, events) = op
.execute(WithdrawExecutionContext {
channels: channels.clone(),
tx_hash: *tx_hash,
})
.map_err(mantle::Error::Channel)?;
self.mantle_ledger = self.mantle_ledger.update_channels(result.channels);
tx_events.extend(events);
}
Op::ChannelTransfer(op) => {
let channels = self.mantle_ledger.channels();
let utxos = self.cryptarchia_ledger.latest_utxos();
let context = ChannelTransferExecutionContext {
channels: channels.clone(),
utxos: utxos.clone(),
tx_hash: *tx_hash,
};
let (result, events) = op.execute(context).map_err(mantle::Error::Channel)?;
self.mantle_ledger = self.mantle_ledger.update_channels(result.channels);
self.cryptarchia_ledger = self.cryptarchia_ledger.update_utxos(result.utxos);
tx_events.extend(events);
}
Op::SDPDeclare(op) => {
let (result, events) = self.mantle_ledger.try_apply_sdp_declaration(
op,
self.cryptarchia_ledger.latest_utxos(),
config,
)?;
self.mantle_ledger = result;
tx_events.extend(events);
}
Op::SDPActive(op) => {
let (result, events) = self.mantle_ledger.try_apply_sdp_active(op, config)?;
self.mantle_ledger = result;
tx_events.extend(events);
}
Op::SDPWithdraw(op) => {
let (result, events) = self.mantle_ledger.try_apply_sdp_withdraw(op, config)?;
self.mantle_ledger = result;
tx_events.extend(events);
}
Op::LeaderClaim(op) => {
let (result, events) = op
.execute(LeaderClaimExecutionContext {
nullifiers: self.mantle_ledger.leaders.nullifiers_cloned(),
reward_amount: self.mantle_ledger.leaders.reward_amount(),
claimable_rewards: self.mantle_ledger.leaders.claimable_rewards(),
utxos: self.cryptarchia_ledger.latest_utxos().clone(),
tx_hash: *tx_hash,
})
.map_err(mantle::Error::LeaderClaim)?;
self.mantle_ledger
.leaders
.update_nullifiers(result.nullifiers);
self.cryptarchia_ledger = self.cryptarchia_ledger.update_utxos(result.utxos);
self.mantle_ledger
.leaders
.update_rewards(result.claimable_rewards);
tx_events.extend(events);
}
Op::Transfer(op) => {
let transfer_balance;
let events;
(self.cryptarchia_ledger, transfer_balance, events) =
self.cryptarchia_ledger
.try_apply_transfer::<_, Constants>(op)?;
balance = balance
.checked_add(transfer_balance)
.ok_or(LedgerError::BalanceOverflow)?;
tx_events.extend(events);
}
}
Ok((self, balance, tx_events))
}
/// Applies a transaction to the ledger state.
///
/// # Note
///
/// Verification is interleaved with execution: each operation is verified
/// against the current ledger state (via
/// [`SignedMantleTx::verified_ops`]) immediately before it is executed, so
/// an operation may depend on state produced by earlier operations in
/// the same transaction.
///
/// # Returns
///
/// On success, returns the updated ledger state, the net balance change of
/// the transaction, and any events emitted during execution.
///
/// If any operation fails verification or execution, returns a
/// [`LedgerError`] describing the failure.
fn try_apply_tx<'tx, Tx, Id, Constants: GasConstants>(
mut self,
config: &Config,
tx: &'tx Tx,
) -> Result<(Self, Balance, Vec<TxEvent>), LedgerError<Id>>
where
Tx: PreverifiedMantleTx + 'tx + MantleTxWithProofs<Context = GasPrices>,
{
let mut verified_ops = tx.verified_ops();
let mut balance: Balance = 0;
let mut tx_events = Vec::new();
loop {
let helper = MantleOperationVerificationHelper::new(
&self.mantle_ledger,
&self.cryptarchia_ledger,
config,
);
let Some(op) = verified_ops.next(&helper).transpose()? else {
break;
};
(self, balance, tx_events) = self.try_apply_op::<_, Constants>(
op,
config,
verified_ops.tx_hash(),
balance,
tx_events,
)?;
}
Ok((self, balance, tx_events))
}
}
#[cfg(test)]
mod tests {
use cryptarchia::tests::{config, generate_proof, utxo};
use lb_core::{
events::TxEventPayload,
mantle::{
GasCalculator as _, MantleTx, Note, OpProof, SignedMantleTx,
gas::MainnetGasConstants,
ledger::{Inputs, Outputs, Utxos},
ops::{
OpId as _,
channel::{
ChannelId, MsgId,
config::ChannelConfigOp,
deposit::{DepositOp, Metadata},
inscribe::InscriptionOp,
withdraw::ChannelWithdrawOp,
},
leader_claim::{LeaderClaimError, LeaderClaimOp, LeaderClaimValidationContext},
sdp::SDPActiveOp,
transfer::TransferOp,
},
traits::Hashable as _,
transactions::{
Ops, OpsProofs,
states::{Preverified, Unverified},
},
},
proofs::{
channel_multi_sig_proof::{ChannelMultiSigProof, IndexedSignature},
leader_claim_proof::Groth16LeaderClaimProof,
},
sdp::{ActivityMetadata, DeclarationId, Nonce},
};
use lb_cryptarchia_engine::Epoch;
use lb_groth16::{CompressedGroth16Proof, Field as _};
use lb_key_management_system_keys::keys::{Ed25519Key, Ed25519PublicKey, ZkKey, ZkPublicKey};
use num_bigint::BigUint;
use super::*;
use crate::{
cryptarchia::tests::{
apply_and_add_utxo, apply_and_add_utxo_and_declaration, declaration_in_snapshot,
ledger, update_ledger, utxo_with_sk,
},
mantle::{leader::LeaderState, sdp::test_utils::generate_activity_proof},
};
fn create_test_keys() -> (Ed25519Key, Ed25519PublicKey) {
create_test_keys_with_seed(0)
}
type HeaderId = [u8; 32];
fn create_tx(
inputs: Vec<NoteId>,
outputs: Vec<Note>,
sks: &[ZkKey],
) -> SignedMantleTx<Unverified> {
let transfer_op = TransferOp::new(
Inputs::try_new(inputs).expect("Invalid inputs size"),
Outputs::try_new(outputs).expect("Invalid outputs size"),
);
let mantle_tx = MantleTx([Op::Transfer(transfer_op)].into());
let ops_proofs = [OpProof::ZkSig(
ZkKey::multi_sign(sks, &mantle_tx.hash().to_fr()).unwrap(),
)]
.into();
SignedMantleTx::new(mantle_tx, ops_proofs)
}
pub fn create_test_ledger() -> (Ledger<HeaderId>, HeaderId, Utxo) {
let config = config();
let utxo = utxo();
let genesis_state = LedgerState::from_utxos([utxo], &config);
let ledger = Ledger::new([0; 32], genesis_state, config);
(ledger, [0; 32], utxo)
}
/// The genesis epoch-state active-declarations snapshots must be seeded
/// from the genesis SDP ledger, not left as the empty default the
/// cryptarchia genesis constructor initializes them with.
#[test]
fn genesis_seeds_epoch_state_sdp_from_mantle() {
let config = config();
let ledger = LedgerState::from_utxos([utxo()], &config);
let expected_for_epoch_0 = ledger
.mantle_ledger
.sdp
.active_declarations(0.into(), &config.sdp_config.service_params);
let expected_for_epoch_1 = ledger
.mantle_ledger
.sdp
.active_declarations(1.into(), &config.sdp_config.service_params);
assert_eq!(
*ledger.epoch_state().active_declarations,
expected_for_epoch_0
);
assert_eq!(
*ledger.next_epoch_state().active_declarations,
expected_for_epoch_1
);
}
fn create_test_keys_with_seed(seed: u8) -> (Ed25519Key, Ed25519PublicKey) {
let signing_key = Ed25519Key::from_bytes(&[seed; 32]);
let verifying_key = signing_key.public_key();
(signing_key, verifying_key)
}
fn update_ledger_prices(ledger_state: &mut LedgerState, new_execution: u64, new_storage: u64) {
ledger_state.cryptarchia_ledger = ledger_state
.cryptarchia_ledger
.clone()
.set_storage_price(new_storage.into());
ledger_state.cryptarchia_ledger = ledger_state
.cryptarchia_ledger
.clone()
.set_execution_base_fee(new_execution.into());
}
enum Key {
Ed25519(Ed25519Key),
Zk(ZkKey),
EmptyZk,
MultiSequencer(ChannelMultiSigProof),
}
fn create_signed_tx(op: Op, signing_key: &Key) -> SignedMantleTx<Preverified> {
create_multi_signed_tx(vec![op], vec![signing_key])
}
fn create_multi_signed_tx(
ops: Vec<Op>,
signing_keys: Vec<&Key>,
) -> SignedMantleTx<Preverified> {
let mantle_tx = MantleTx(Ops::new_unchecked(ops.clone()));
let tx_hash = mantle_tx.hash();
let ops_proofs = signing_keys
.into_iter()
.zip(ops)
.map(|(key, _)| match key {
Key::Ed25519(key) => {
OpProof::Ed25519Sig(key.sign_payload(tx_hash.as_signing_bytes().as_ref()))
}
Key::Zk(key) => OpProof::ZkSig(
ZkKey::multi_sign(std::slice::from_ref(key), &tx_hash.to_fr()).unwrap(),
),
Key::EmptyZk => OpProof::ZkSig(ZkKey::multi_sign(&[], &tx_hash.to_fr()).unwrap()),
Key::MultiSequencer(proof) => OpProof::ChannelMultiSigProof(proof.clone()),
})
.collect::<Vec<_>>();
let ops_proofs = OpsProofs::try_from(ops_proofs).expect("operation proofs are bounded");
SignedMantleTx::new(mantle_tx, ops_proofs)
.preverify()
.expect("Test transaction should have valid signatures")
}
fn create_channel(
ledger_state: LedgerState,
config: &Config,
id: ChannelId,
signing_key: &Ed25519Key,
verifying_key: Ed25519PublicKey,
) -> LedgerState {
let tx = create_signed_tx(
Op::ChannelInscribe(InscriptionOp {
channel_id: id,
inscription: [1, 2, 3, 4].into(),
parent: MsgId::root(),
signer: verifying_key,
}),
&Key::Ed25519(signing_key.clone()),
);
ledger_state
.try_apply_tx::<_, HeaderId, MainnetGasConstants>(config, &tx)
.unwrap()
.0
}
#[expect(clippy::too_many_arguments, reason = "test fn")]
fn apply_and_add_utxo_and_activity(
ledger: &mut Ledger<HeaderId>,
parent: HeaderId,
slot: impl Into<Slot>,
target_epoch_state: &EpochState,
utxo_proof: Utxo,
utxo_add: Utxo,
declaration_id: DeclarationId,
zk_key: &ZkKey,
nonce: Nonce,
) -> HeaderId {
let id = apply_and_add_utxo(ledger, parent, slot, utxo_proof, utxo_add);
let current_epoch_state = ledger
.states
.get(&id)
.unwrap()
.cryptarchia_ledger
.epoch_state
.clone();
let config = ledger.config().clone();
let active_op = SDPActiveOp {
declaration_id,
nonce,
metadata: ActivityMetadata::Blend(Box::new(generate_activity_proof(
zk_key,
target_epoch_state,
&current_epoch_state,
&config.sdp_config.service_rewards_params.blend,
))),
};
let block_ledger = ledger.states.get_mut(&id).unwrap();
block_ledger.mantle_ledger = block_ledger
.mantle_ledger
.clone()
.try_apply_sdp_active(&active_op, &config)
.unwrap()
.0;
id
}
#[test]
fn test_ledger_creation() {
let (ledger, genesis_id, utxo) = create_test_ledger();
let state = ledger.state(&genesis_id).unwrap();
assert!(state.latest_utxos().contains(&utxo.id()));
assert_eq!(state.slot(), 0.into());
}
#[test]
fn test_ledger_try_update_with_transaction() {
let (mut ledger, genesis_id, utxo) = create_test_ledger();
let mut output_note = Note::new(1, ZkPublicKey::new(BigUint::from(1u8).into()));
let sk = ZkKey::from(BigUint::from(0u8));
// determine fees
let tx = create_tx(
vec![utxo.id()],
vec![output_note],
std::slice::from_ref(&sk),
);
let default_gas_prices = GasPrices::default();
let fees = tx
.total_gas_cost::<MainnetGasConstants>(&default_gas_prices)
.unwrap();
output_note.value = utxo.note.value - fees.into_inner();
let tx = create_tx(vec![utxo.id()], vec![output_note], &[sk])
.preverify()
.unwrap();
let mantle_tx = tx.mantle_tx().clone();
// Create a dummy proof (using same structure as in cryptarchia tests)
let proof = generate_proof(
&ledger.state(&genesis_id).unwrap().cryptarchia_ledger,
&utxo,
Slot::from(1u64),
);
let new_id = [1; 32];
let (_, state, events) = ledger
.prepare_update::<_, _, MainnetGasConstants>(
new_id,
genesis_id,
Slot::from(1u64),
&proof,
std::iter::once(&tx),
)
.unwrap();
assert!(events.is_empty());
ledger.commit_update(new_id, state);
// Verify the transaction was applied
let new_state = ledger.state(&new_id).unwrap();
assert!(!new_state.latest_utxos().contains(&utxo.id()));
// Verify output was created
if let Op::Transfer(transfer_op) = &mantle_tx.ops()[0] {
let output_utxo = transfer_op.outputs.utxo_by_index(0, transfer_op).unwrap();
assert!(new_state.latest_utxos().contains(&output_utxo.id()));
} else {
panic!("first op must be a transfer")
}
}
#[test]
fn test_channel_inscribe_operation() {
let test_config = config();
let state = LedgerState::from_utxos([utxo()], &test_config);
let (signing_key, verifying_key) = create_test_keys();
let channel_id = ChannelId::from([2; 32]);
let inscribe_op = InscriptionOp {
channel_id,
inscription: [1, 2, 3, 4].into(),
parent: MsgId::root(),
signer: verifying_key,
};
let tx = create_signed_tx(Op::ChannelInscribe(inscribe_op), &Key::Ed25519(signing_key));
let result = state.try_apply_tx::<_, HeaderId, MainnetGasConstants>(&test_config, &tx);
assert!(result.is_ok());
let (new_state, _, events) = result.unwrap();
assert!(
new_state
.mantle_ledger
.channels()
.channels
.contains_key(&channel_id)
);
assert!(events.is_empty());
}
#[test]
fn test_channel_config_operation() {
let test_config = config();
let state = LedgerState::from_utxos([utxo()], &test_config);
let (signing_key, verifying_key) = create_test_keys();
let channel_id = ChannelId::from([3; 32]);
let config_op = ChannelConfigOp {
channel: channel_id,
keys: verifying_key.into(),
posting_timeframe: 0.into(),
posting_timeout: 0.into(),
configuration_threshold: 1,
transfer_threshold: 1,
};
let config_tx = MantleTx([Op::ChannelConfig(config_op.clone())].into());
let config_tx_hash = config_tx.hash();
let config_proof = ChannelMultiSigProof::try_new(
[IndexedSignature::new(
0,
signing_key.sign_payload(config_tx_hash.as_signing_bytes().as_ref()),
)]
.into(),
)
.unwrap();
let tx = create_signed_tx(
Op::ChannelConfig(config_op),
&Key::MultiSequencer(config_proof),
);
let result = state.try_apply_tx::<_, HeaderId, MainnetGasConstants>(&test_config, &tx);
assert!(result.is_ok());
let (new_state, _, events) = result.unwrap();
assert!(
new_state
.mantle_ledger
.channels()
.channels
.contains_key(&channel_id)
);
assert_eq!(
*new_state
.mantle_ledger
.channels()
.channels
.get(&channel_id)
.unwrap()
.accredited_keys,
verifying_key.into()
);
assert!(events.is_empty());
}
#[test]
fn test_channel_deposit_operation() {
let test_config = config();
let (sk, utxo) = utxo_with_sk();
let mut ledger_state = LedgerState::from_utxos([utxo], &test_config);
let (signing_key, verifying_key) = create_test_keys();
let channel_id = ChannelId::from([4; 32]);
// First, create a channel by submitting an inscription
ledger_state = create_channel(
ledger_state,
&test_config,
channel_id,
&signing_key,
verifying_key,
);
assert!(
ledger_state
.mantle_ledger()
.channels()
.channels
.contains_key(&channel_id)
);
// Submit a deposit operation
let deposit = DepositOp {
channel_id,
inputs: Inputs::new([utxo.id()]),
metadata: [5, 6, 7, 8].into(),
};
let ops = vec![Op::ChannelDeposit(deposit.clone())];
let tx = create_multi_signed_tx(ops, vec![&Key::Zk(sk)]);
let result =
ledger_state.try_apply_tx::<_, HeaderId, MainnetGasConstants>(&test_config, &tx);
let (new_state, balance, events) = result.unwrap();
// The deposited note is now owned by the channel but stays in the ledger.
assert!(
new_state
.mantle_ledger()
.channels()
.is_channel_note(&utxo.id())
);
assert!(new_state.latest_utxos().contains(&utxo.id()));
assert_eq!(balance, Balance::from(0));
assert_eq!(events.len(), 1);
let Some(TxEvent {
tx_hash: event_tx_hash,
op_id,
payload:
TxEventPayload::Deposit {
channel_id: event_channel_id,
amount,
metadata,
},
}) = events.iter().find(|event| {
matches!(
event,
TxEvent {
payload: TxEventPayload::Deposit { .. },
..
}
)
})
else {
panic!("events should include deposit event")
};
assert_eq!(*event_tx_hash, tx.hash());
assert_eq!(*op_id, deposit.op_id());
assert_eq!(*event_channel_id, deposit.channel_id);
assert_eq!(*amount, utxo.note.value);
assert_eq!(*metadata, deposit.metadata);
}
#[test]
fn test_channel_withdraw_operation() {
let test_config = config();
let (sk, utxo) = utxo_with_sk();
let mut ledger_state = LedgerState::from_utxos([utxo], &test_config);
let (signing_key, verifying_key) = create_test_keys();
let channel_id = ChannelId::from([9; 32]);
ledger_state = create_channel(
ledger_state,
&test_config,
channel_id,
&signing_key,
verifying_key,
);
// Deposit some funds into the channel
let deposit = DepositOp {
channel_id,
inputs: Inputs::new([utxo.id()]),
metadata: [5, 6, 7, 8].into(),
};
let deposit_ops = vec![Op::ChannelDeposit(deposit)];
let tx = create_multi_signed_tx(deposit_ops, vec![&Key::Zk(sk)]);
ledger_state = ledger_state
.try_apply_tx::<_, HeaderId, MainnetGasConstants>(&test_config, &tx)
.unwrap()
.0;
assert!(
ledger_state
.mantle_ledger
.channels()
.is_channel_note(&utxo.id())
);
// Withdraw the channel note, releasing it back to a regular note
let withdraw = ChannelWithdrawOp {
channel_id,
inputs: Inputs::new([utxo.id()]),
};
let withdraw_tx = MantleTx([Op::ChannelWithdraw(withdraw)].into());
let withdraw_tx_hash = withdraw_tx.hash();
let withdraw_proof = ChannelMultiSigProof::try_new(
[IndexedSignature::new(
0,
signing_key.sign_payload(withdraw_tx_hash.as_signing_bytes().as_ref()),
)]
.into(),
)
.unwrap();
let signed_tx = create_multi_signed_tx(
withdraw_tx.0.to_vec(),
vec![&Key::MultiSequencer(withdraw_proof)],
);
let result =
ledger_state.try_apply_tx::<_, HeaderId, MainnetGasConstants>(&test_config, &signed_tx);
assert!(result.is_ok());
let (new_state, tx_balance, events) = result.unwrap();
assert_eq!(tx_balance, 0);
// The note is released from the channel and remains spendable in the ledger.
assert!(
!new_state
.mantle_ledger()
.channels()
.is_channel_note(&utxo.id())
);
assert!(new_state.latest_utxos().contains(&utxo.id()));
assert!(events.is_empty());
}
#[test]
fn test_channel_withdraw_invalid_helper_backed_proof_fails_on_apply() {
let test_config = config();
let (sk, utxo) = utxo_with_sk();
let mut ledger_state = LedgerState::from_utxos([utxo], &test_config);
let (signing_key, verifying_key) = create_test_keys();
let channel_id = ChannelId::from([10; 32]);
ledger_state = create_channel(
ledger_state,
&test_config,
channel_id,
&signing_key,
verifying_key,
);
// Deposit some funds into the channel
let deposit = DepositOp {
channel_id,
inputs: Inputs::new([utxo.id()]),
metadata: Metadata::empty(),
};
let deposit_ops = vec![Op::ChannelDeposit(deposit)];
let tx = create_multi_signed_tx(deposit_ops, vec![&Key::Zk(sk)]);
ledger_state = ledger_state
.try_apply_tx::<_, HeaderId, MainnetGasConstants>(&test_config, &tx)
.unwrap()
.0;
// The deposit made the note a channel note
assert!(
ledger_state
.mantle_ledger()
.channels()
.is_channel_note(&utxo.id())
);
// Try to withdraw the channel note, but with an invalid proof
let withdraw = ChannelWithdrawOp {
channel_id,
inputs: Inputs::new([utxo.id()]),
};
let wrong_key = Ed25519Key::from_bytes(&[42; 32]);
let withdraw_tx = MantleTx([Op::ChannelWithdraw(withdraw)].into());
let withdraw_tx_hash = withdraw_tx.hash();
let invalid_proof = ChannelMultiSigProof::try_new(
[IndexedSignature::new(
0,
wrong_key.sign_payload(withdraw_tx_hash.as_signing_bytes().as_ref()),
)]
.into(),
)
.unwrap();
let signed_tx = create_multi_signed_tx(
withdraw_tx.0.to_vec(),
vec![&Key::MultiSequencer(invalid_proof), &Key::EmptyZk],
);
let err = ledger_state
.clone()
.try_apply_tx::<_, HeaderId, MainnetGasConstants>(&test_config, &signed_tx)
.unwrap_err();
assert_eq!(
err,
LedgerError::VerificationError(
VerificationError::ChannelMultiSigProofInvalidSignature {
op_index: 0,
signature_index: 0,
}
)
);
// The rejected withdraw left the note owned by the channel
assert!(
ledger_state
.mantle_ledger()
.channels()
.is_channel_note(&utxo.id())
);
}
#[test]
fn test_invalid_parent_error() {
let test_config = config();
let mut state = LedgerState::from_utxos([utxo()], &test_config);
let (signing_key, verifying_key) = create_test_keys();
let channel_id = ChannelId::from([5; 32]);
// First, create a channel with one message
let first_inscribe = InscriptionOp {
channel_id,
inscription: [1, 2, 3].into(),
parent: MsgId::root(),
signer: verifying_key,
};
let first_tx = create_signed_tx(
Op::ChannelInscribe(first_inscribe),
&Key::Ed25519(signing_key.clone()),
);
state = state
.try_apply_tx::<_, HeaderId, MainnetGasConstants>(&test_config, &first_tx)
.unwrap()
.0;
// Now try to add a message with wrong parent
let wrong_parent = MsgId::from([99; 32]);
let second_inscribe = InscriptionOp {
channel_id,
inscription: [4, 5, 6].into(),
parent: wrong_parent,
signer: verifying_key,
};
let second_tx = create_signed_tx(
Op::ChannelInscribe(second_inscribe),
&Key::Ed25519(signing_key.clone()),
);
let result = state
.clone()
.try_apply_tx::<_, HeaderId, MainnetGasConstants>(&test_config, &second_tx);
assert!(matches!(
result,
Err(LedgerError::VerificationError(
VerificationError::ChannelVerificationError(
mantle::channel::Error::InvalidParent { .. }
)
))
));
// Writing into an empty channel with a parent != MsgId::root() should also fail
let empty_channel_id = ChannelId::from([8; 32]);
let empty_inscribe = InscriptionOp {
channel_id: empty_channel_id,
inscription: [7, 8, 9].into(),
parent: MsgId::from([1; 32]), // non-root parent
signer: verifying_key,
};
let empty_tx = create_signed_tx(
Op::ChannelInscribe(empty_inscribe),
&Key::Ed25519(signing_key),
);
let empty_result =
state.try_apply_tx::<_, HeaderId, MainnetGasConstants>(&test_config, &empty_tx);
assert!(matches!(
empty_result,
Err(LedgerError::VerificationError(
VerificationError::ChannelVerificationError(
mantle::channel::Error::InvalidParent { .. }
)
))
));
}
#[test]
fn test_unauthorized_signer_error() {
let test_config = config();
let mut state = LedgerState::from_utxos([utxo()], &test_config);
let (signing_key, verifying_key) = create_test_keys();
let (unauthorized_signing_key, unauthorized_verifying_key) = create_test_keys_with_seed(3);
let channel_id = ChannelId::from([6; 32]);
// First, create a channel with authorized signer
let first_inscribe = InscriptionOp {
channel_id,
inscription: [1, 2, 3].into(),
parent: MsgId::root(),
signer: verifying_key,
};
let correct_parent = first_inscribe.id();
let first_tx = create_signed_tx(
Op::ChannelInscribe(first_inscribe),
&Key::Ed25519(signing_key),
);
state = state
.try_apply_tx::<_, HeaderId, MainnetGasConstants>(&test_config, &first_tx)
.unwrap()
.0;
// Now try to add a message with unauthorized signer
let second_inscribe = InscriptionOp {
channel_id,
inscription: [4, 5, 6].into(),
parent: correct_parent,
signer: unauthorized_verifying_key,
};
let second_tx = create_signed_tx(
Op::ChannelInscribe(second_inscribe),
&Key::Ed25519(unauthorized_signing_key),
);
let result =
state.try_apply_tx::<_, HeaderId, MainnetGasConstants>(&test_config, &second_tx);
assert!(matches!(
result,
Err(LedgerError::VerificationError(
VerificationError::ChannelVerificationError(
mantle::channel::Error::UnauthorizedSigner { .. }
)
))
));
}
#[test]
fn test_multiple_operations_in_transaction() {
// Create channel 1 by posting an inscription
// Create channel 2 by posting an inscription
// Change the keys for channel 1
// Post another inscription in channel 1
let test_config = config();
let state = LedgerState::from_utxos([utxo()], &test_config);
let (sk1, vk1) = create_test_keys_with_seed(1);
let (sk2, vk2) = create_test_keys_with_seed(2);
let (sk3, vk3) = create_test_keys_with_seed(3);
let (_, vk4) = create_test_keys_with_seed(4);
let channel1 = ChannelId::from([10; 32]);
let channel2 = ChannelId::from([20; 32]);
let inscribe_op1 = InscriptionOp {
channel_id: channel1,
inscription: [1, 2, 3].into(),
parent: MsgId::root(),
signer: vk1,
};
let inscribe_op2 = InscriptionOp {
channel_id: channel2,
inscription: [4, 5, 6].into(),
parent: MsgId::root(),
signer: vk2,
};
let config_op = ChannelConfigOp {
channel: channel1,
keys: [vk3, vk4].into(),
posting_timeframe: 0.into(),
posting_timeout: 0.into(),
configuration_threshold: 1,
transfer_threshold: 1,
};
let inscribe_op3 = InscriptionOp {
channel_id: channel1,
inscription: [7, 8, 9].into(),
parent: config_op.id(),
signer: vk3,
};
let ops = vec![
Op::ChannelInscribe(inscribe_op1),
Op::ChannelInscribe(inscribe_op2),
Op::ChannelConfig(config_op),
Op::ChannelInscribe(inscribe_op3.clone()),
];
let config_tx = MantleTx(Ops::new_unchecked(ops.clone()));
let config_tx_hash = config_tx.hash();
let config_proof = ChannelMultiSigProof::try_new(
[IndexedSignature::new(
0,
sk1.sign_payload(config_tx_hash.as_signing_bytes().as_ref()),
)]
.into(),
)
.unwrap();
let tx = create_multi_signed_tx(
ops,
vec![
&Key::Ed25519(sk1),
&Key::Ed25519(sk2),
&Key::MultiSequencer(config_proof),
&Key::Ed25519(sk3),
],
);
let result = state
.try_apply_tx::<_, HeaderId, MainnetGasConstants>(&test_config, &tx)
.unwrap()
.0;
assert!(
result
.mantle_ledger
.channels()
.channels
.contains_key(&channel1)
);
assert!(
result
.mantle_ledger
.channels()
.channels
.contains_key(&channel2)
);
assert_eq!(
result
.mantle_ledger
.channels()
.channels
.get(&channel1)
.unwrap()
.tip_message,
inscribe_op3.id()
);
}
/// Tests the snapshot-finalization-delay scenario behind the
/// `inactivity_period >= SNAPSHOT_FINALIZATION_DELAY` invariant:
///
/// - A declaration is created at epoch 3 — `active = created +
/// SNAPSHOT_FINALIZATION_DELAY = 5`.
/// - An activity message accepted at epoch 6 refreshes the live SDP's
/// `active` to 6.
/// - But the snapshot for epoch 7 was built at the 5→6 transition, before
/// the activity message landed. The snapshot sees the decl with
/// `active=5`, not 6.
///
/// With `inactivity_period = SNAPSHOT_FINALIZATION_DELAY = 2`, the
/// filter at epoch 7 is `5 + 2 ≥ 7` → INCLUDED. The decl survives the
/// finalization-delay gap.
#[test]
fn snapshot_includes_decl_when_active_refresh_lags_finalization() {
let leader_utxo = utxo();
let (_sdp_utxo_key, sdp_utxo) = utxo_with_sk();
let new_utxo_1 = utxo();
let new_utxo_2 = utxo();
let config = config();
let epoch_length = config.epoch_length();
let (mut ledger, genesis) = ledger(&[leader_utxo, sdp_utxo], config);
// Declare at the first slot of epoch 3 — `active = 3 + 2 = 5`.
let h_1 = update_ledger(&mut ledger, genesis, epoch_length, leader_utxo).unwrap();
let h_2 = update_ledger(&mut ledger, h_1, 2 * epoch_length, leader_utxo).unwrap();
let (h_3, declare, zk_key) = apply_and_add_utxo_and_declaration(
&mut ledger,
h_2,
3 * epoch_length,
leader_utxo,
new_utxo_1,
sdp_utxo,
);
// Advance to the first slot of epoch 6 and submit an Active message.
// This sets the live SDP's `active` to 6.
let h_4 = update_ledger(&mut ledger, h_3, 4 * epoch_length, leader_utxo).unwrap();
let h_5 = update_ledger(&mut ledger, h_4, 5 * epoch_length, leader_utxo).unwrap();
let epoch5 = ledger.states[&h_5].cryptarchia_ledger.epoch_state.clone();
let h_6 = apply_and_add_utxo_and_activity(
&mut ledger,
h_5,
6 * epoch_length,
&epoch5,
leader_utxo,
new_utxo_2,
declare.id(),
&zk_key,
1,
);
// Advance to epoch 7. The snapshot for epoch 7 was built at the 5→6
// transition, before the epoch-6 Active was applied.
let h_7 = update_ledger(&mut ledger, h_6, 7 * epoch_length, leader_utxo).unwrap();
assert_eq!(
ledger.states[&h_7].cryptarchia_ledger.epoch_state.epoch,
Epoch::new(7)
);
let decl = declaration_in_snapshot(&ledger, &h_7, &declare.id()).expect(
"decl must be in the epoch-7 because inactivity_period >= SNAPSHOT_FINALIZATION_DELAY",
);
assert_eq!(
decl.active,
Epoch::new(5),
"decl must have active=5 because the snapshot was taken at the end of epoch 5 before the activity message was accepted"
);
// Advance to epoch 8. The snapshot for epoch 8 was built at the 6→7
// transition, after the epoch-6 Active was applied.
let h_8 = update_ledger(&mut ledger, h_7, 8 * epoch_length, leader_utxo).unwrap();
assert_eq!(
ledger.states[&h_8].cryptarchia_ledger.epoch_state.epoch,
Epoch::new(8)
);
let decl = declaration_in_snapshot(&ledger, &h_8, &declare.id()).expect(
"decl must be in the epoch-7 because inactivity_period >= SNAPSHOT_FINALIZATION_DELAY",
);
assert_eq!(
decl.active,
Epoch::new(6),
"decl must have active=6 because the snapshot was taken at the end of epoch 6 after the activity message was accepted"
);
}
// TODO: Update this test to work with the new SDP API
// This test needs to be rewritten to use the new SDP ledger API which no longer
// exposes get_declaration() or uses declaration_id() methods.
// #[test]
// #[expect(clippy::, reason = "Test function.")]
#[test]
fn _test_sdp_withdraw_operation() {
// This test has been disabled pending API updates
}
#[test]
fn test_fee_rejection() {
let utxo = utxo();
let config = config();
let mut ledger = LedgerState::from_utxos([utxo], &config);
update_ledger_prices(&mut ledger, 1, 1);
let mut output_note = Note::new(1, ZkPublicKey::new(BigUint::from(0u8).into()));
let sk = ZkKey::from(BigUint::from(0u8));
let tx = create_tx(
vec![utxo.id()],
vec![output_note],
std::slice::from_ref(&sk),
);
// Pays 2925 fees = 2705 execution base fee + 0 execution tip + 220 storage
let fees = tx
.total_gas_cost::<MainnetGasConstants>(&ledger.get_gas_prices())
.unwrap();
output_note.value = utxo.note.value - fees.into_inner();
let tx = create_tx(vec![utxo.id()], vec![output_note], &[sk])
.preverify()
.unwrap();
let result = ledger
.clone()
.try_apply_contents::<_, HeaderId, MainnetGasConstants>(&config, std::iter::once(&tx));
// The `unwrap` should succeed because the user pays at least the base fee of
// 2705
result.unwrap();
ledger.cryptarchia_ledger = ledger.cryptarchia_ledger.set_execution_base_fee(10.into());
let err = ledger
.try_apply_contents::<_, HeaderId, MainnetGasConstants>(&config, std::iter::once(&tx))
.unwrap_err();
// The transaction should be rejected because the price indicated for execution
// doesn't cover the base fee that cost 27 050
assert_eq!(err, LedgerError::InsufficientBalance);
}
#[test]
fn test_priority_fees_go_to_leader() {
let utxo = utxo();
let config = config();
let mut ledger = LedgerState::from_utxos([utxo], &config);
let mut output_note = Note::new(1, ZkPublicKey::new(BigUint::from(0u8).into()));
let sk = ZkKey::from(BigUint::from(0u8));
let tx = create_tx(
vec![utxo.id()],
vec![output_note],
std::slice::from_ref(&sk),
);
update_ledger_prices(&mut ledger, 1, 1);
// The tx pays 794 fees = 590 execution base fee + 0 execution tip + 204
// storage
let fees = tx
.total_gas_cost::<MainnetGasConstants>(&ledger.get_gas_prices())
.unwrap();
output_note.value = utxo.note.value - fees.into_inner();
let tx = create_tx(
vec![utxo.id()],
vec![output_note],
std::slice::from_ref(&sk),
)
.preverify()
.unwrap();
let result = ledger
.clone()
.try_apply_contents::<_, HeaderId, MainnetGasConstants>(&config, std::iter::once(&tx));
// The `unwrap` should succeed because the user pays at least the base fee of
// 794
let (no_priority_fee_ledger, events) = result.unwrap();
assert!(events.is_empty());
// The tx ays 1794 fees = 590 execution base fee + 1000 execution tip + 204
// storage
output_note.value = utxo.note.value - fees.into_inner() - 1000;
let tx = create_tx(
vec![utxo.id()],
vec![output_note],
std::slice::from_ref(&sk),
)
.preverify()
.unwrap();
let result = ledger
.try_apply_contents::<_, HeaderId, MainnetGasConstants>(&config, std::iter::once(&tx));
// The `unwrap` should succeed because the user pays at least the base fee of
// 794
let (priority_fee_ledger, events) = result.unwrap();
assert_eq!(
no_priority_fee_ledger
.mantle_ledger
.leaders
.get_pending_rewards()
+ 1000,
priority_fee_ledger
.mantle_ledger
.leaders
.get_pending_rewards()
);
assert!(events.is_empty());
}
#[test]
fn test_apply_contents_accumulates_storage_gas() {
let utxo = utxo();
let config = config();
let mut ledger = LedgerState::from_utxos([utxo], &config);
update_ledger_prices(&mut ledger, 1, 1);
// No outputs: the whole input covers the gas cost and the remainder is
// tipped. We only assert on the storage counter, not the tip, so the
// exact balance is irrelevant.
let sk = ZkKey::from(BigUint::from(0u8));
let tx = create_tx(vec![utxo.id()], vec![], std::slice::from_ref(&sk))
.preverify()
.unwrap();
// The tx must consume a non-zero amount of storage gas for the check to
// be meaningful.
let storage_gas = tx
.storage_gas_consumption(&ledger.get_gas_prices())
.unwrap();
assert!(storage_gas.into_inner() > 0);
let (applied, _) = ledger
.try_apply_contents::<_, HeaderId, MainnetGasConstants>(&config, std::iter::once(&tx))
.unwrap();
// Storage gas consumed by the tx should be accumulated in the ledger
assert_eq!(
applied.cryptarchia_ledger.storage_gas_consumed_in_epoch(),
storage_gas
);
}
#[test]
fn test_leader_claim_operation() {
let leaders = LeaderState::new();
// Add 3 vouchers (blocks) at epoch 1
let leaders = leaders.try_apply_header(1.into(), Fr::ZERO.into()).unwrap();
let leaders = leaders.try_apply_header(1.into(), Fr::ONE.into()).unwrap();
let leaders = leaders
.try_apply_header(1.into(), Fr::from(2u64).into())
.unwrap();
// Advance to epoch 2 by adding a voucher (block)
let mut leaders = leaders
.try_apply_header(2.into(), Fr::from(3u64).into())
.unwrap();
// Set rewards to 300 which can be distributed to the 3 vouchers
// collected so far (during epoch 1).
leaders.update_rewards(300);
// For each of the 3 vouchers, claim the reward.
for nf in [Fr::ZERO, Fr::ONE, Fr::from(2u64)] {
assert_eq!(leaders.reward_amount(), 100);
let op = LeaderClaimOp {
rewards_root: *leaders.vouchers_snapshot_root(),
voucher_nullifier: nf.into(),
pk: ZkPublicKey::zero(),
};
// Skip `op.validate` in this test to avoid having to generate a valid proof
let (result, _events) = op
.execute(LeaderClaimExecutionContext {
nullifiers: leaders.nullifiers_cloned(),
reward_amount: leaders.reward_amount(),
claimable_rewards: leaders.claimable_rewards(),
utxos: Utxos::new(),
tx_hash: TxHash::from([0u8; 32]),
})
.unwrap();
leaders.update_nullifiers(result.nullifiers);
leaders.update_rewards(result.claimable_rewards);
assert_eq!(result.utxos.size(), 1);
let (_, (utxo, _)) = result.utxos.utxos().iter().next().unwrap();
assert_eq!(utxo.note.value, 100);
}
// All rewards have been claimed.
assert_eq!(leaders.claimable_rewards(), 0);
}
#[test]
fn test_duplicate_leader_claim_is_rejected() {
let leaders = LeaderState::new();
// Add a voucher (block) at epoch 1
let leaders = leaders.try_apply_header(1.into(), Fr::ZERO.into()).unwrap();
// Advance to epoch 2 by adding a voucher (block)
let mut leaders = leaders.try_apply_header(2.into(), Fr::ONE.into()).unwrap();
// Set rewards to 100 which can be distributed to the vouchers
// collected so far (during epoch 1).
leaders.update_rewards(100);
// Claim the reward for the 1st voucher.
let op = LeaderClaimOp {
rewards_root: *leaders.vouchers_snapshot_root(),
voucher_nullifier: Fr::ZERO.into(), // nf of the 1st voucher
pk: ZkPublicKey::zero(),
};
// Skip `op.validate` in this test to avoid having to generate a valid proof
let (result, _events) = op
.execute(LeaderClaimExecutionContext {
nullifiers: leaders.nullifiers_cloned(),
reward_amount: leaders.reward_amount(),
claimable_rewards: leaders.claimable_rewards(),
utxos: Utxos::new(),
tx_hash: TxHash::from([0u8; 32]),
})
.unwrap();
leaders.update_nullifiers(result.nullifiers);
leaders.update_rewards(result.claimable_rewards);
assert_eq!(result.utxos.size(), 1);
let (_, (utxo, _)) = result.utxos.utxos().iter().next().unwrap();
assert_eq!(utxo.note.value, 100);
// Try to claim the reward using the same nullifier.
let err = op
.validate(&LeaderClaimValidationContext {
nullifiers: leaders.nullifiers(),
claimable_vouchers_root: leaders.vouchers_snapshot_root(),
// Use a dummy proof since duplication is detected before proof verification
proof_of_claim: &Groth16LeaderClaimProof::new(CompressedGroth16Proof::from_bytes(
&[0u8; 128],
)),
tx_hash: &TxHash::from([0u8; 32]),
})
.unwrap_err();
assert_eq!(err, LeaderClaimError::DuplicatedVoucherNullifier);
}
}