diff --git a/CHANGELOG.md b/CHANGELOG.md index b87c320a5..4095eb1ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **mina-node-account**: move tests into `node/account/tests`, document the library and run the tests in CI ([#1540](https://github.com/o1-labs/mina-rust/pull/1540)). +- **Ledger**: add tests to verify some properties transaction application + should have on the ledger. Also, document the different types of transactions + that can be used to modify the ledger +([#1541](https://github.com/o1-labs/mina-rust/pull/1541)) ### Changed diff --git a/ledger/src/scan_state/transaction_logic/mod.rs b/ledger/src/scan_state/transaction_logic/mod.rs index 82e033656..14749c868 100644 --- a/ledger/src/scan_state/transaction_logic/mod.rs +++ b/ledger/src/scan_state/transaction_logic/mod.rs @@ -1051,10 +1051,51 @@ impl GenericTransaction for Transaction { } } +/// Top-level transaction type representing all possible transactions in the +/// Mina protocol. +/// +/// Transactions in Mina fall into two categories: +/// +/// ## User-initiated transactions +/// +/// - [`Command`](Transaction::Command): User-initiated transactions that can be +/// either signed commands (payments and stake delegations) or zkApp commands +/// (complex multi-account zero-knowledge operations). These transactions are +/// submitted by users, require signatures, and pay fees to block producers. +/// +/// ## Protocol transactions +/// +/// - [`FeeTransfer`](Transaction::FeeTransfer): System-generated transaction +/// that distributes collected transaction fees to block producers. Created +/// automatically during block production and does not require user signatures. +/// - [`Coinbase`](Transaction::Coinbase): System-generated transaction that +/// rewards block producers for successfully producing a block. May include an +/// optional fee transfer component to split rewards. +/// +/// # Transaction processing +/// +/// All transactions are processed through the two-phase application model +/// ([`apply_transaction_first_pass`] and [`apply_transaction_second_pass`]) to +/// enable efficient proof generation. Protocol transactions (fee transfers and +/// coinbase) complete entirely in the first pass, while user commands may +/// require both passes. +/// +/// # Serialization +/// +/// The type uses [`derive_more::From`] for automatic conversion from variant +/// types and implements conversion to/from the p2p wire format +/// [`MinaTransactionTransactionStableV2`]. +/// +/// OCaml reference: src/lib/transaction/transaction.ml L:8-11 +/// Commit: 5da42ccd72e791f164d4d200cf1ce300262873b3 +/// Last verified: 2025-10-10 #[derive(Clone, Debug, derive_more::From)] pub enum Transaction { + /// User-initiated transaction: signed command or zkApp command Command(UserCommand), + /// System-generated fee distribution to block producers FeeTransfer(FeeTransfer), + /// System-generated block reward for block producer Coinbase(Coinbase), } diff --git a/ledger/src/scan_state/transaction_logic/transaction_applied.rs b/ledger/src/scan_state/transaction_logic/transaction_applied.rs index 707599afb..9d83d6952 100644 --- a/ledger/src/scan_state/transaction_logic/transaction_applied.rs +++ b/ledger/src/scan_state/transaction_logic/transaction_applied.rs @@ -1,13 +1,13 @@ -use mina_core::constants::ConstraintConstants; -use mina_curves::pasta::Fp; - -use crate::{Account, AccountId}; - use super::{ signed_command, zkapp_command, Coinbase, FeeTransfer, Transaction, TransactionStatus, UserCommand, WithStatus, }; -use crate::scan_state::currency::{Amount, Magnitude, Signed}; +use crate::{ + scan_state::currency::{Amount, Magnitude, Signed}, + Account, AccountId, +}; +use mina_core::constants::ConstraintConstants; +use mina_curves::pasta::Fp; pub mod signed_command_applied { use mina_signer::CompressedPubKey; diff --git a/ledger/src/scan_state/transaction_logic/transaction_partially_applied.rs b/ledger/src/scan_state/transaction_logic/transaction_partially_applied.rs index 0437ac6f1..34c076c0e 100644 --- a/ledger/src/scan_state/transaction_logic/transaction_partially_applied.rs +++ b/ledger/src/scan_state/transaction_logic/transaction_partially_applied.rs @@ -678,33 +678,6 @@ fn update_timing_when_no_deduction( validate_timing(account, Amount::zero(), txn_global_slot) } -// /// TODO: Move this to the ledger -// /// -// fn get_or_create( -// ledger: &mut L, -// account_id: &AccountId, -// ) -> Result<(AccountState, Account, Address), String> -// where -// L: LedgerIntf, -// { -// let location = ledger -// .get_or_create_account(account_id.clone(), Account::initialize(account_id)) -// .map_err(|e| format!("{:?}", e))?; - -// let action = match location { -// GetOrCreated::Added(_) => AccountState::Added, -// GetOrCreated::Existed(_) => AccountState::Existed, -// }; - -// let addr = location.addr(); - -// let account = ledger -// .get(addr.clone()) -// .expect("get_or_create: Account was not found in the ledger after creation"); - -// Ok((action, account, addr)) -// } - fn get_new_accounts(action: AccountState, data: T) -> Option { match action { AccountState::Added => Some(data), diff --git a/ledger/src/scan_state/transaction_logic/transaction_witness.rs b/ledger/src/scan_state/transaction_logic/transaction_witness.rs index 23dfb76b2..f7a270952 100644 --- a/ledger/src/scan_state/transaction_logic/transaction_witness.rs +++ b/ledger/src/scan_state/transaction_logic/transaction_witness.rs @@ -1,9 +1,9 @@ -use mina_p2p_messages::v2::MinaStateProtocolStateBodyValueStableV2; - -use crate::{scan_state::pending_coinbase::Stack, sparse_ledger::SparseLedger}; - use super::{Transaction, TransactionStatus}; -use crate::scan_state::currency::Slot; +use crate::{ + scan_state::{currency::Slot, pending_coinbase::Stack}, + sparse_ledger::SparseLedger, +}; +use mina_p2p_messages::v2::MinaStateProtocolStateBodyValueStableV2; /// #[derive(Debug)] diff --git a/ledger/src/transaction_pool.rs b/ledger/src/transaction_pool.rs index 57eb37bac..f6eb38d16 100644 --- a/ledger/src/transaction_pool.rs +++ b/ledger/src/transaction_pool.rs @@ -1,15 +1,3 @@ -use backtrace::Backtrace; -use serde::{Deserialize, Serialize}; -use std::{ - borrow::{Borrow, Cow}, - collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}, -}; - -use itertools::Itertools; -use mina_core::{bug_condition, consensus::ConsensusConstants}; -use mina_curves::pasta::Fp; -use mina_p2p_messages::{bigint::BigInt, v2}; - use crate::{ scan_state::{ currency::{Amount, Balance, BlockTime, Fee, Magnitude, Nonce, Slot}, @@ -27,6 +15,16 @@ use crate::{ verifier::{Verifier, VerifierError}, Account, AccountId, BaseLedger, Mask, TokenId, VerificationKey, VerificationKeyWire, }; +use backtrace::Backtrace; +use itertools::Itertools; +use mina_core::{bug_condition, consensus::ConsensusConstants}; +use mina_curves::pasta::Fp; +use mina_p2p_messages::{bigint::BigInt, v2}; +use serde::{Deserialize, Serialize}; +use std::{ + borrow::{Borrow, Cow}, + collections::{hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}, +}; #[derive(Debug, thiserror::Error)] pub enum TransactionPoolErrors { @@ -589,16 +587,6 @@ pub struct IndexedPoolConfig { slot_tx_end: Option, } -// module Config = struct -// type t = -// { constraint_constants : Genesis_constants.Constraint_constants.t -// ; consensus_constants : Consensus.Constants.t -// ; time_controller : Block_time.Controller.t -// ; slot_tx_end : Mina_numbers.Global_slot_since_hard_fork.t option -// } -// [@@deriving sexp_of, equal, compare] -// end - #[derive(Clone, Debug, Serialize, Deserialize)] pub struct IndexedPool { /// Transactions valid against the current ledger, indexed by fee per diff --git a/ledger/src/tree.rs b/ledger/src/tree.rs index ca523fd5e..1aa137c5f 100644 --- a/ledger/src/tree.rs +++ b/ledger/src/tree.rs @@ -1,5 +1,3 @@ -use std::{collections::BTreeMap, fmt::Debug, sync::Mutex}; - use crate::{ address::Address, base::AccountIndex, @@ -7,6 +5,7 @@ use crate::{ }; use mina_curves::pasta::Fp; use once_cell::sync::Lazy; +use std::{collections::BTreeMap, fmt::Debug, sync::Mutex}; #[derive(Clone, Debug)] struct Leaf { diff --git a/ledger/src/tree_version.rs b/ledger/src/tree_version.rs index f1b942651..6d37f81ed 100644 --- a/ledger/src/tree_version.rs +++ b/ledger/src/tree_version.rs @@ -1,9 +1,7 @@ -use std::{fmt::Debug, hash::Hash}; - +use crate::account::{get_legacy_hash_of, Account, AccountLegacy, TokenId, TokenIdLegacy}; use mina_curves::pasta::Fp; use poseidon::hash::params::get_merkle_param_for_height; - -use crate::account::{get_legacy_hash_of, Account, AccountLegacy, TokenId, TokenIdLegacy}; +use std::{fmt::Debug, hash::Hash}; pub trait TreeVersion { type Account: Debug + Clone; @@ -34,14 +32,9 @@ impl TreeVersion for V2 { } fn empty_hash_at_height(height: usize) -> Fp { - // let now = redux::Instant::now(); - (0..height).fold(Account::empty().hash(), |prev_hash, height| { Self::hash_node(height, prev_hash, prev_hash) }) - // elog!("empty_hash_at_height={:?} {:?}", height, now.elapsed()); - - // res } } diff --git a/ledger/tests/test_transaction_logic_first_pass_coinbase.rs b/ledger/tests/test_transaction_logic_first_pass_coinbase.rs new file mode 100644 index 000000000..c2754517b --- /dev/null +++ b/ledger/tests/test_transaction_logic_first_pass_coinbase.rs @@ -0,0 +1,382 @@ +//! Tests for apply_transaction_first_pass with coinbase transactions +//! +//! Run with: cargo test --test test_transaction_logic_first_pass_coinbase +//! +//! Tests the first pass of two-phase transaction application for coinbase +//! rewards, covering: +//! - Successful coinbase without fee transfer +//! - Successful coinbase with fee transfer to different account +//! - Coinbase with fee transfer to same account (fee transfer should be +//! removed) +//! - Coinbase creating a new account + +use ark_ff::Zero; +use mina_core::constants::ConstraintConstants; +use mina_curves::pasta::Fp; +use mina_tree::{ + scan_state::{ + currency::{Amount, Balance, Fee, Length, Magnitude, Nonce, Slot}, + transaction_logic::{ + protocol_state::{EpochData, EpochLedger, ProtocolStateView}, + transaction_partially_applied::apply_transaction_first_pass, + Coinbase, CoinbaseFeeTransfer, Transaction, + }, + }, + Account, AccountId, BaseLedger, Database, Mask, +}; + +fn dummy_epoch_data() -> EpochData { + EpochData { + ledger: EpochLedger { + hash: Fp::zero(), + total_currency: Amount::zero(), + }, + seed: Fp::zero(), + start_checkpoint: Fp::zero(), + lock_checkpoint: Fp::zero(), + epoch_length: Length::from_u32(0), + } +} + +fn test_constraint_constants() -> ConstraintConstants { + ConstraintConstants { + sub_windows_per_window: 11, + ledger_depth: 15, + work_delay: 2, + block_window_duration_ms: 180_000, + transaction_capacity_log_2: 7, + pending_coinbase_depth: 5, + coinbase_amount: 720_000_000_000, + supercharged_coinbase_factor: 2, + account_creation_fee: 1_000_000_000, + fork: None, + } +} + +fn create_test_ledger() -> Mask { + let db = Database::create(15); + let mut ledger = Mask::new_root(db); + let alice = mina_signer::PubKey::from_address( + "B62qmnY6m4c6bdgSPnQGZriSaj9vuSjsfh6qkveGTsFX3yGA5ywRaja", + ) + .unwrap() + .into_compressed(); + + // Create Alice's account with balance + let alice_id = AccountId::new(alice, Default::default()); + let alice_account = Account::create_with(alice_id.clone(), Balance::from_u64(1_000_000_000)); + ledger + .get_or_create_account(alice_id, alice_account) + .unwrap(); + + ledger +} + +#[test] +fn test_apply_coinbase_without_fee_transfer() { + let mut ledger = create_test_ledger(); + + let alice_pk = mina_signer::PubKey::from_address( + "B62qmnY6m4c6bdgSPnQGZriSaj9vuSjsfh6qkveGTsFX3yGA5ywRaja", + ) + .unwrap() + .into_compressed(); + + let alice_id = AccountId::new(alice_pk.clone(), Default::default()); + + // Record initial state + let alice_location = ledger.location_of_account(&alice_id).unwrap(); + let alice_before = ledger.get(alice_location).unwrap(); + let initial_alice_balance = alice_before.balance; + + // Create a coinbase of 720 MINA to Alice with no fee transfer + let coinbase_amount = Amount::from_u64(720_000_000_000); + let coinbase = Coinbase::create(coinbase_amount, alice_pk.clone(), None).unwrap(); + + let constraint_constants = &test_constraint_constants(); + let state_view = ProtocolStateView { + snarked_ledger_hash: Fp::zero(), + blockchain_length: Length::from_u32(0), + min_window_density: Length::from_u32(0), + total_currency: Amount::zero(), + global_slot_since_genesis: Slot::from_u32(0), + staking_epoch_data: dummy_epoch_data(), + next_epoch_data: dummy_epoch_data(), + }; + let result = apply_transaction_first_pass( + constraint_constants, + Slot::from_u32(0), + &state_view, + &mut ledger, + &Transaction::Coinbase(coinbase), + ); + + assert!(result.is_ok()); + + // Verify ledger state changes + let alice_location = ledger.location_of_account(&alice_id).unwrap(); + let alice_after = ledger.get(alice_location).unwrap(); + + // Verify Alice's balance increased by coinbase amount + let expected_alice_balance = initial_alice_balance.add_amount(coinbase_amount).unwrap(); + assert_eq!( + alice_after.balance, expected_alice_balance, + "Alice's balance should increase by coinbase amount" + ); + + // Verify Alice's nonce unchanged (coinbase doesn't affect nonces) + assert_eq!( + alice_after.nonce, alice_before.nonce, + "Alice's nonce should remain unchanged" + ); +} + +#[test] +fn test_apply_coinbase_with_fee_transfer() { + let mut ledger = create_test_ledger(); + + let alice_pk = mina_signer::PubKey::from_address( + "B62qmnY6m4c6bdgSPnQGZriSaj9vuSjsfh6qkveGTsFX3yGA5ywRaja", + ) + .unwrap() + .into_compressed(); + let bob_pk = mina_signer::PubKey::from_address( + "B62qjVQLxt9nYMWGn45mkgwYfcz8e8jvjNCBo11VKJb7vxDNwv5QLPS", + ) + .unwrap() + .into_compressed(); + + let alice_id = AccountId::new(alice_pk.clone(), Default::default()); + + // Create Bob's account + let bob_id = AccountId::new(bob_pk.clone(), Default::default()); + let bob_account = Account::create_with(bob_id.clone(), Balance::from_u64(500_000_000)); + ledger + .get_or_create_account(bob_id.clone(), bob_account) + .unwrap(); + + // Record initial state + let alice_location = ledger.location_of_account(&alice_id).unwrap(); + let alice_before = ledger.get(alice_location).unwrap(); + let bob_location = ledger.location_of_account(&bob_id).unwrap(); + let bob_before = ledger.get(bob_location).unwrap(); + let initial_alice_balance = alice_before.balance; + let initial_bob_balance = bob_before.balance; + + // Create a coinbase of 720 MINA to Alice with a 10 MINA fee transfer to Bob + let coinbase_amount = Amount::from_u64(720_000_000_000); + let fee_transfer_amount = Fee::from_u64(10_000_000_000); + let fee_transfer = CoinbaseFeeTransfer::create(bob_pk.clone(), fee_transfer_amount); + let coinbase = Coinbase::create(coinbase_amount, alice_pk.clone(), Some(fee_transfer)).unwrap(); + + let constraint_constants = &test_constraint_constants(); + let state_view = ProtocolStateView { + snarked_ledger_hash: Fp::zero(), + blockchain_length: Length::from_u32(0), + min_window_density: Length::from_u32(0), + total_currency: Amount::zero(), + global_slot_since_genesis: Slot::from_u32(0), + staking_epoch_data: dummy_epoch_data(), + next_epoch_data: dummy_epoch_data(), + }; + let result = apply_transaction_first_pass( + constraint_constants, + Slot::from_u32(0), + &state_view, + &mut ledger, + &Transaction::Coinbase(coinbase), + ); + + assert!(result.is_ok()); + + // Verify ledger state changes + let alice_location = ledger.location_of_account(&alice_id).unwrap(); + let alice_after = ledger.get(alice_location).unwrap(); + let bob_location = ledger.location_of_account(&bob_id).unwrap(); + let bob_after = ledger.get(bob_location).unwrap(); + + // Verify Alice's balance increased by (coinbase amount - fee transfer amount) + // The fee transfer is deducted from the coinbase reward + let coinbase_after_fee_transfer = coinbase_amount + .checked_sub(&Amount::of_fee(&fee_transfer_amount)) + .unwrap(); + let expected_alice_balance = initial_alice_balance + .add_amount(coinbase_after_fee_transfer) + .unwrap(); + assert_eq!( + alice_after.balance, expected_alice_balance, + "Alice's balance should increase by coinbase minus fee transfer" + ); + + // Verify Bob's balance increased by fee transfer amount + let expected_bob_balance = initial_bob_balance + .add_amount(Amount::of_fee(&fee_transfer_amount)) + .unwrap(); + assert_eq!( + bob_after.balance, expected_bob_balance, + "Bob's balance should increase by fee transfer amount" + ); + + // Verify nonces unchanged + assert_eq!( + alice_after.nonce, alice_before.nonce, + "Alice's nonce should remain unchanged" + ); + assert_eq!( + bob_after.nonce, bob_before.nonce, + "Bob's nonce should remain unchanged" + ); +} + +/// Test coinbase with fee transfer to the same account. +/// +/// When the coinbase receiver and fee transfer receiver are the same, the fee +/// transfer should be removed during coinbase creation, and the receiver +/// should only receive the coinbase amount (not coinbase + fee transfer). +/// +/// Ledger state: Receiver gets only coinbase amount. +#[test] +fn test_apply_coinbase_with_fee_transfer_to_same_account() { + let mut ledger = create_test_ledger(); + + let alice_pk = mina_signer::PubKey::from_address( + "B62qmnY6m4c6bdgSPnQGZriSaj9vuSjsfh6qkveGTsFX3yGA5ywRaja", + ) + .unwrap() + .into_compressed(); + + let alice_id = AccountId::new(alice_pk.clone(), Default::default()); + + // Record initial state + let alice_location = ledger.location_of_account(&alice_id).unwrap(); + let alice_before = ledger.get(alice_location).unwrap(); + let initial_alice_balance = alice_before.balance; + + // Create a coinbase of 720 MINA to Alice with a 10 MINA fee transfer also + // to Alice. The fee transfer should be removed. + let coinbase_amount = Amount::from_u64(720_000_000_000); + let fee_transfer_amount = Fee::from_u64(10_000_000_000); + let fee_transfer = CoinbaseFeeTransfer::create(alice_pk.clone(), fee_transfer_amount); + let coinbase = Coinbase::create(coinbase_amount, alice_pk.clone(), Some(fee_transfer)).unwrap(); + + // Verify that the fee transfer was removed during creation + assert!( + coinbase.fee_transfer.is_none(), + "Fee transfer should be None when receiver equals fee transfer receiver" + ); + + let constraint_constants = &test_constraint_constants(); + let state_view = ProtocolStateView { + snarked_ledger_hash: Fp::zero(), + blockchain_length: Length::from_u32(0), + min_window_density: Length::from_u32(0), + total_currency: Amount::zero(), + global_slot_since_genesis: Slot::from_u32(0), + staking_epoch_data: dummy_epoch_data(), + next_epoch_data: dummy_epoch_data(), + }; + let result = apply_transaction_first_pass( + constraint_constants, + Slot::from_u32(0), + &state_view, + &mut ledger, + &Transaction::Coinbase(coinbase), + ); + + assert!(result.is_ok()); + + // Verify ledger state changes + let alice_location = ledger.location_of_account(&alice_id).unwrap(); + let alice_after = ledger.get(alice_location).unwrap(); + + // Verify Alice's balance increased by ONLY coinbase amount (not coinbase + + // fee transfer) + let expected_alice_balance = initial_alice_balance.add_amount(coinbase_amount).unwrap(); + assert_eq!( + alice_after.balance, expected_alice_balance, + "Alice's balance should increase by only coinbase amount" + ); + + // Verify Alice's nonce unchanged + assert_eq!( + alice_after.nonce, alice_before.nonce, + "Alice's nonce should remain unchanged" + ); +} + +/// Test coinbase to a nonexistent account. +/// +/// The receiver account does not exist, so the coinbase should create it with +/// the coinbase amount as balance. +/// +/// Ledger state: New account created with the coinbase amount as balance. +#[test] +fn test_apply_coinbase_creates_account() { + let db = Database::create(15); + let mut ledger = Mask::new_root(db); + + let bob_pk = mina_signer::PubKey::from_address( + "B62qjVQLxt9nYMWGn45mkgwYfcz8e8jvjNCBo11VKJb7vxDNwv5QLPS", + ) + .unwrap() + .into_compressed(); + + let bob_id = AccountId::new(bob_pk.clone(), Default::default()); + + // Verify Bob's account does not exist before the transaction + assert!( + ledger.location_of_account(&bob_id).is_none(), + "Bob's account should not exist before transaction" + ); + + // Create a coinbase of 720 MINA to Bob (who doesn't exist yet) + let coinbase_amount = Amount::from_u64(720_000_000_000); + let coinbase = Coinbase::create(coinbase_amount, bob_pk.clone(), None).unwrap(); + + let constraint_constants = &test_constraint_constants(); + let state_view = ProtocolStateView { + snarked_ledger_hash: Fp::zero(), + blockchain_length: Length::from_u32(0), + min_window_density: Length::from_u32(0), + total_currency: Amount::zero(), + global_slot_since_genesis: Slot::from_u32(0), + staking_epoch_data: dummy_epoch_data(), + next_epoch_data: dummy_epoch_data(), + }; + let result = apply_transaction_first_pass( + constraint_constants, + Slot::from_u32(0), + &state_view, + &mut ledger, + &Transaction::Coinbase(coinbase), + ); + + assert!(result.is_ok()); + + // Verify Bob's account was created + let bob_location = ledger.location_of_account(&bob_id); + assert!( + bob_location.is_some(), + "Bob's account should exist after transaction" + ); + + // Verify Bob's balance equals the coinbase amount minus account creation fee + let bob_account = ledger.get(bob_location.unwrap()).unwrap(); + let account_creation_fee = constraint_constants.account_creation_fee; + let expected_balance = Balance::from_u64( + coinbase_amount + .as_u64() + .saturating_sub(account_creation_fee), + ); + assert_eq!( + bob_account.balance, expected_balance, + "Bob's balance should equal coinbase minus account creation fee" + ); + + // Verify Bob's nonce is 0 (new account) + assert_eq!( + bob_account.nonce, + Nonce::zero(), + "Bob's nonce should be 0 for new account" + ); +} diff --git a/ledger/tests/test_transaction_logic_first_pass_delegation.rs b/ledger/tests/test_transaction_logic_first_pass_delegation.rs new file mode 100644 index 000000000..1999e378a --- /dev/null +++ b/ledger/tests/test_transaction_logic_first_pass_delegation.rs @@ -0,0 +1,440 @@ +//! Tests for apply_transaction_first_pass with stake delegation transactions +//! +//! Run with: cargo test --test test_transaction_logic_first_pass_delegation +//! +//! Tests the first pass of two-phase transaction application for stake +//! delegations, covering: +//! - Successful delegation to a new delegate +//! - Delegation with insufficient balance for fee +//! - Delegation with invalid nonce +//! - Delegation from nonexistent fee payer + +use ark_ff::Zero; +use mina_core::constants::ConstraintConstants; +use mina_curves::pasta::Fp; +use mina_tree::{ + scan_state::{ + currency::{Amount, Balance, Fee, Length, Magnitude, Nonce, Slot}, + transaction_logic::{ + protocol_state::{EpochData, EpochLedger, ProtocolStateView}, + signed_command::{ + Body, Common, SignedCommand, SignedCommandPayload, StakeDelegationPayload, + }, + transaction_partially_applied::apply_transaction_first_pass, + Memo, Transaction, UserCommand, + }, + }, + Account, AccountId, BaseLedger, Database, Mask, +}; + +fn dummy_epoch_data() -> EpochData { + EpochData { + ledger: EpochLedger { + hash: Fp::zero(), + total_currency: Amount::zero(), + }, + seed: Fp::zero(), + start_checkpoint: Fp::zero(), + lock_checkpoint: Fp::zero(), + epoch_length: Length::from_u32(0), + } +} + +fn test_constraint_constants() -> ConstraintConstants { + ConstraintConstants { + sub_windows_per_window: 11, + ledger_depth: 15, + work_delay: 2, + block_window_duration_ms: 180_000, + transaction_capacity_log_2: 7, + pending_coinbase_depth: 5, + coinbase_amount: 720_000_000_000, + supercharged_coinbase_factor: 2, + account_creation_fee: 1_000_000_000, + fork: None, + } +} + +fn create_test_ledger() -> Mask { + let db = Database::create(15); + let mut ledger = Mask::new_root(db); + let alice = mina_signer::PubKey::from_address( + "B62qmnY6m4c6bdgSPnQGZriSaj9vuSjsfh6qkveGTsFX3yGA5ywRaja", + ) + .unwrap() + .into_compressed(); + let bob = mina_signer::PubKey::from_address( + "B62qjVQLxt9nYMWGn45mkgwYfcz8e8jvjNCBo11VKJb7vxDNwv5QLPS", + ) + .unwrap() + .into_compressed(); + + // Create Alice's account with balance + let alice_id = AccountId::new(alice, Default::default()); + let alice_account = Account::create_with(alice_id.clone(), Balance::from_u64(1_000_000_000)); + ledger + .get_or_create_account(alice_id, alice_account) + .unwrap(); + + // Create Bob's account (potential delegate) + let bob_id = AccountId::new(bob, Default::default()); + let bob_account = Account::create_with(bob_id.clone(), Balance::from_u64(500_000_000)); + ledger.get_or_create_account(bob_id, bob_account).unwrap(); + + ledger +} + +fn create_delegation( + from_pk: &mina_signer::CompressedPubKey, + to_pk: &mina_signer::CompressedPubKey, + fee: u64, + nonce: u32, +) -> SignedCommand { + let payload = SignedCommandPayload { + common: Common { + fee: Fee::from_u64(fee), + fee_payer_pk: from_pk.clone(), + nonce: Nonce::from_u32(nonce), + valid_until: Slot::max(), + memo: Memo::empty(), + }, + body: Body::StakeDelegation(StakeDelegationPayload::SetDelegate { + new_delegate: to_pk.clone(), + }), + }; + + SignedCommand { + payload, + signer: from_pk.clone(), + signature: mina_signer::Signature::dummy(), + } +} + +#[test] +fn test_apply_delegation_success() { + let mut ledger = create_test_ledger(); + + let alice_pk = mina_signer::PubKey::from_address( + "B62qmnY6m4c6bdgSPnQGZriSaj9vuSjsfh6qkveGTsFX3yGA5ywRaja", + ) + .unwrap() + .into_compressed(); + let bob_pk = mina_signer::PubKey::from_address( + "B62qjVQLxt9nYMWGn45mkgwYfcz8e8jvjNCBo11VKJb7vxDNwv5QLPS", + ) + .unwrap() + .into_compressed(); + + let alice_id = AccountId::new(alice_pk.clone(), Default::default()); + let bob_id = AccountId::new(bob_pk.clone(), Default::default()); + + // Record initial state + let alice_location = ledger.location_of_account(&alice_id).unwrap(); + let alice_before = ledger.get(alice_location).unwrap(); + let bob_location = ledger.location_of_account(&bob_id).unwrap(); + let bob_before = ledger.get(bob_location).unwrap(); + + let initial_alice_balance = alice_before.balance; + let initial_bob_balance = bob_before.balance; + let initial_alice_nonce = alice_before.nonce; + let initial_alice_receipt_hash = alice_before.receipt_chain_hash; + let initial_alice_delegate = alice_before.delegate.clone(); + + let fee = 10_000_000; + let nonce = 0; + let delegation = create_delegation(&alice_pk, &bob_pk, fee, nonce); + + let constraint_constants = &test_constraint_constants(); + let state_view = ProtocolStateView { + snarked_ledger_hash: Fp::zero(), + blockchain_length: Length::from_u32(0), + min_window_density: Length::from_u32(0), + total_currency: Amount::zero(), + global_slot_since_genesis: Slot::from_u32(0), + staking_epoch_data: dummy_epoch_data(), + next_epoch_data: dummy_epoch_data(), + }; + let result = apply_transaction_first_pass( + constraint_constants, + Slot::from_u32(0), + &state_view, + &mut ledger, + &Transaction::Command(UserCommand::SignedCommand(Box::new(delegation))), + ); + + assert!(result.is_ok()); + + // Verify ledger state changes + let alice_location = ledger.location_of_account(&alice_id).unwrap(); + let alice_after = ledger.get(alice_location).unwrap(); + let bob_location = ledger.location_of_account(&bob_id).unwrap(); + let bob_after = ledger.get(bob_location).unwrap(); + + // Verify Alice's balance decreased by fee only (no amount transferred in + // delegation) + let expected_alice_balance = initial_alice_balance + .sub_amount(Amount::from_u64(fee)) + .unwrap(); + assert_eq!( + alice_after.balance, expected_alice_balance, + "Alice's balance should decrease by fee only" + ); + + // Verify Alice's nonce incremented + assert_eq!( + alice_after.nonce, + initial_alice_nonce.incr(), + "Alice's nonce should be incremented" + ); + + // Verify Alice's receipt chain hash updated + assert_ne!( + alice_after.receipt_chain_hash, initial_alice_receipt_hash, + "Alice's receipt chain hash should be updated" + ); + + // Verify Alice's delegate was set to Bob + assert_eq!( + alice_after.delegate.as_ref(), + Some(&bob_pk), + "Alice's delegate should be set to Bob" + ); + assert_ne!( + alice_after.delegate, initial_alice_delegate, + "Alice's delegate should have changed" + ); + + // Verify Bob's balance unchanged (he's the delegate, not receiving funds) + assert_eq!( + bob_after.balance, initial_bob_balance, + "Bob's balance should remain unchanged" + ); + + // Verify Bob's nonce unchanged + assert_eq!( + bob_after.nonce, bob_before.nonce, + "Bob's nonce should not change" + ); +} + +/// Test delegation with insufficient balance for the fee. +/// +/// The transaction is rejected during fee payment validation because the +/// account balance is less than the required fee. +/// +/// Ledger state: Remains unchanged (no fee charged, no nonce incremented, +/// delegate not changed). +#[test] +fn test_apply_delegation_insufficient_balance() { + let db = Database::create(15); + let mut ledger = Mask::new_root(db); + + let alice_pk = mina_signer::PubKey::from_address( + "B62qmnY6m4c6bdgSPnQGZriSaj9vuSjsfh6qkveGTsFX3yGA5ywRaja", + ) + .unwrap() + .into_compressed(); + let bob_pk = mina_signer::PubKey::from_address( + "B62qjVQLxt9nYMWGn45mkgwYfcz8e8jvjNCBo11VKJb7vxDNwv5QLPS", + ) + .unwrap() + .into_compressed(); + + // Create Alice's account with very small balance + let alice_id = AccountId::new(alice_pk.clone(), Default::default()); + let alice_account = Account::create_with(alice_id.clone(), Balance::from_u64(1_000_000)); + ledger + .get_or_create_account(alice_id.clone(), alice_account) + .unwrap(); + + // Create Bob's account + let bob_id = AccountId::new(bob_pk.clone(), Default::default()); + let bob_account = Account::create_with(bob_id.clone(), Balance::from_u64(500_000_000)); + ledger.get_or_create_account(bob_id, bob_account).unwrap(); + + // Record initial state + let alice_location = ledger.location_of_account(&alice_id).unwrap(); + let alice_before = ledger.get(alice_location).unwrap(); + let initial_alice_balance = alice_before.balance; + let initial_alice_nonce = alice_before.nonce; + let initial_alice_delegate = alice_before.delegate.clone(); + + let fee = 10_000_000; // More than Alice's balance + let nonce = 0; + let delegation = create_delegation(&alice_pk, &bob_pk, fee, nonce); + + let constraint_constants = &test_constraint_constants(); + let state_view = ProtocolStateView { + snarked_ledger_hash: Fp::zero(), + blockchain_length: Length::from_u32(0), + min_window_density: Length::from_u32(0), + total_currency: Amount::zero(), + global_slot_since_genesis: Slot::from_u32(0), + staking_epoch_data: dummy_epoch_data(), + next_epoch_data: dummy_epoch_data(), + }; + let result = apply_transaction_first_pass( + constraint_constants, + Slot::from_u32(0), + &state_view, + &mut ledger, + &Transaction::Command(UserCommand::SignedCommand(Box::new(delegation))), + ); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "insufficient funds"); + + // Verify ledger state remains unchanged + let alice_location = ledger.location_of_account(&alice_id).unwrap(); + let alice_after = ledger.get(alice_location).unwrap(); + + assert_eq!( + alice_after.balance, initial_alice_balance, + "Alice's balance should remain unchanged" + ); + assert_eq!( + alice_after.nonce, initial_alice_nonce, + "Alice's nonce should remain unchanged" + ); + assert_eq!( + alice_after.delegate, initial_alice_delegate, + "Alice's delegate should remain unchanged" + ); +} + +/// Test delegation with incorrect nonce. +/// +/// The transaction is rejected during fee payment validation because the +/// provided nonce does not match the account's current nonce. +/// +/// Ledger state: Remains unchanged (no fee charged, no nonce incremented). +#[test] +fn test_apply_delegation_invalid_nonce() { + let mut ledger = create_test_ledger(); + + let alice_pk = mina_signer::PubKey::from_address( + "B62qmnY6m4c6bdgSPnQGZriSaj9vuSjsfh6qkveGTsFX3yGA5ywRaja", + ) + .unwrap() + .into_compressed(); + let bob_pk = mina_signer::PubKey::from_address( + "B62qjVQLxt9nYMWGn45mkgwYfcz8e8jvjNCBo11VKJb7vxDNwv5QLPS", + ) + .unwrap() + .into_compressed(); + + let alice_id = AccountId::new(alice_pk.clone(), Default::default()); + + // Record initial state + let alice_location = ledger.location_of_account(&alice_id).unwrap(); + let alice_before = ledger.get(alice_location).unwrap(); + let initial_alice_balance = alice_before.balance; + let initial_alice_nonce = alice_before.nonce; + let initial_alice_delegate = alice_before.delegate.clone(); + + let fee = 10_000_000; + let nonce = 5; // Wrong nonce (should be 0) + let delegation = create_delegation(&alice_pk, &bob_pk, fee, nonce); + + let constraint_constants = &test_constraint_constants(); + let state_view = ProtocolStateView { + snarked_ledger_hash: Fp::zero(), + blockchain_length: Length::from_u32(0), + min_window_density: Length::from_u32(0), + total_currency: Amount::zero(), + global_slot_since_genesis: Slot::from_u32(0), + staking_epoch_data: dummy_epoch_data(), + next_epoch_data: dummy_epoch_data(), + }; + let result = apply_transaction_first_pass( + constraint_constants, + Slot::from_u32(0), + &state_view, + &mut ledger, + &Transaction::Command(UserCommand::SignedCommand(Box::new(delegation))), + ); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "Nonce in account Nonce(0) different from nonce in transaction Nonce(5)" + ); + + // Verify ledger state unchanged + let alice_location = ledger.location_of_account(&alice_id).unwrap(); + let alice_after = ledger.get(alice_location).unwrap(); + assert_eq!( + alice_after.balance, initial_alice_balance, + "Alice's balance should remain unchanged" + ); + assert_eq!( + alice_after.nonce, initial_alice_nonce, + "Alice's nonce should remain unchanged" + ); + assert_eq!( + alice_after.delegate, initial_alice_delegate, + "Alice's delegate should remain unchanged" + ); +} + +/// Test delegation from a nonexistent fee payer account. +/// +/// The transaction is rejected during fee payment validation because the +/// fee payer account does not exist in the ledger. +/// +/// Ledger state: Remains unchanged (no new account created). +#[test] +fn test_apply_delegation_nonexistent_fee_payer() { + let db = Database::create(15); + let mut ledger = Mask::new_root(db); + + let alice_pk = mina_signer::PubKey::from_address( + "B62qmnY6m4c6bdgSPnQGZriSaj9vuSjsfh6qkveGTsFX3yGA5ywRaja", + ) + .unwrap() + .into_compressed(); + let bob_pk = mina_signer::PubKey::from_address( + "B62qjVQLxt9nYMWGn45mkgwYfcz8e8jvjNCBo11VKJb7vxDNwv5QLPS", + ) + .unwrap() + .into_compressed(); + + let alice_id = AccountId::new(alice_pk.clone(), Default::default()); + + // Verify Alice's account does not exist before the transaction + assert!( + ledger.location_of_account(&alice_id).is_none(), + "Alice's account should not exist before transaction" + ); + + let fee = 10_000_000; + let nonce = 0; + let delegation = create_delegation(&alice_pk, &bob_pk, fee, nonce); + + let constraint_constants = &test_constraint_constants(); + let state_view = ProtocolStateView { + snarked_ledger_hash: Fp::zero(), + blockchain_length: Length::from_u32(0), + min_window_density: Length::from_u32(0), + total_currency: Amount::zero(), + global_slot_since_genesis: Slot::from_u32(0), + staking_epoch_data: dummy_epoch_data(), + next_epoch_data: dummy_epoch_data(), + }; + let result = apply_transaction_first_pass( + constraint_constants, + Slot::from_u32(0), + &state_view, + &mut ledger, + &Transaction::Command(UserCommand::SignedCommand(Box::new(delegation))), + ); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "The fee-payer account does not exist"); + + // Verify Alice's account still does not exist after the error + assert!( + ledger.location_of_account(&alice_id).is_none(), + "Alice's account should still not exist after transaction error" + ); +} diff --git a/ledger/tests/test_transaction_logic_first_pass_fee_transfer.rs b/ledger/tests/test_transaction_logic_first_pass_fee_transfer.rs new file mode 100644 index 000000000..8ff5b9278 --- /dev/null +++ b/ledger/tests/test_transaction_logic_first_pass_fee_transfer.rs @@ -0,0 +1,347 @@ +//! Tests for apply_transaction_first_pass with fee transfer transactions +//! +//! Run with: cargo test --test test_transaction_logic_first_pass_fee_transfer +//! +//! Tests the first pass of two-phase transaction application for fee transfers, +//! covering: +//! - Successful single fee transfer to one receiver +//! - Successful double fee transfer to two receivers +//! - Fee transfer creating a new account +//! - Fee transfer with incompatible tokens (should fail) + +use ark_ff::Zero; +use mina_core::constants::ConstraintConstants; +use mina_curves::pasta::Fp; +use mina_tree::{ + scan_state::{ + currency::{Amount, Balance, Fee, Length, Magnitude, Nonce, Slot}, + scan_state::transaction_snark::OneOrTwo, + transaction_logic::{ + protocol_state::{EpochData, EpochLedger, ProtocolStateView}, + transaction_partially_applied::apply_transaction_first_pass, + FeeTransfer, SingleFeeTransfer, Transaction, + }, + }, + Account, AccountId, BaseLedger, Database, Mask, TokenId, +}; + +fn dummy_epoch_data() -> EpochData { + EpochData { + ledger: EpochLedger { + hash: Fp::zero(), + total_currency: Amount::zero(), + }, + seed: Fp::zero(), + start_checkpoint: Fp::zero(), + lock_checkpoint: Fp::zero(), + epoch_length: Length::from_u32(0), + } +} + +fn test_constraint_constants() -> ConstraintConstants { + ConstraintConstants { + sub_windows_per_window: 11, + ledger_depth: 15, + work_delay: 2, + block_window_duration_ms: 180_000, + transaction_capacity_log_2: 7, + pending_coinbase_depth: 5, + coinbase_amount: 720_000_000_000, + supercharged_coinbase_factor: 2, + account_creation_fee: 1_000_000_000, + fork: None, + } +} + +fn create_test_ledger() -> Mask { + let db = Database::create(15); + let mut ledger = Mask::new_root(db); + let alice = mina_signer::PubKey::from_address( + "B62qmnY6m4c6bdgSPnQGZriSaj9vuSjsfh6qkveGTsFX3yGA5ywRaja", + ) + .unwrap() + .into_compressed(); + + // Create Alice's account with balance + let alice_id = AccountId::new(alice, Default::default()); + let alice_account = Account::create_with(alice_id.clone(), Balance::from_u64(1_000_000_000)); + ledger + .get_or_create_account(alice_id, alice_account) + .unwrap(); + + ledger +} + +#[test] +fn test_apply_single_fee_transfer_success() { + let mut ledger = create_test_ledger(); + + let alice_pk = mina_signer::PubKey::from_address( + "B62qmnY6m4c6bdgSPnQGZriSaj9vuSjsfh6qkveGTsFX3yGA5ywRaja", + ) + .unwrap() + .into_compressed(); + + let alice_id = AccountId::new(alice_pk.clone(), Default::default()); + + // Record initial state + let alice_location = ledger.location_of_account(&alice_id).unwrap(); + let alice_before = ledger.get(alice_location).unwrap(); + let initial_alice_balance = alice_before.balance; + + // Create a single fee transfer of 10 MINA to Alice + let fee = Fee::from_u64(10_000_000); + let single_transfer = SingleFeeTransfer::create(alice_pk.clone(), fee, TokenId::default()); + let fee_transfer = FeeTransfer::of_singles(OneOrTwo::One(single_transfer)).unwrap(); + + let constraint_constants = &test_constraint_constants(); + let state_view = ProtocolStateView { + snarked_ledger_hash: Fp::zero(), + blockchain_length: Length::from_u32(0), + min_window_density: Length::from_u32(0), + total_currency: Amount::zero(), + global_slot_since_genesis: Slot::from_u32(0), + staking_epoch_data: dummy_epoch_data(), + next_epoch_data: dummy_epoch_data(), + }; + let result = apply_transaction_first_pass( + constraint_constants, + Slot::from_u32(0), + &state_view, + &mut ledger, + &Transaction::FeeTransfer(fee_transfer), + ); + + assert!(result.is_ok()); + + // Verify ledger state changes + let alice_location = ledger.location_of_account(&alice_id).unwrap(); + let alice_after = ledger.get(alice_location).unwrap(); + + // Verify Alice's balance increased by fee amount + let expected_alice_balance = initial_alice_balance + .add_amount(Amount::of_fee(&fee)) + .unwrap(); + assert_eq!( + alice_after.balance, expected_alice_balance, + "Alice's balance should increase by fee amount" + ); + + // Verify Alice's nonce unchanged (fee transfers don't affect nonces) + assert_eq!( + alice_after.nonce, alice_before.nonce, + "Alice's nonce should remain unchanged" + ); +} + +#[test] +fn test_apply_double_fee_transfer_success() { + let mut ledger = create_test_ledger(); + + let alice_pk = mina_signer::PubKey::from_address( + "B62qmnY6m4c6bdgSPnQGZriSaj9vuSjsfh6qkveGTsFX3yGA5ywRaja", + ) + .unwrap() + .into_compressed(); + let bob_pk = mina_signer::PubKey::from_address( + "B62qjVQLxt9nYMWGn45mkgwYfcz8e8jvjNCBo11VKJb7vxDNwv5QLPS", + ) + .unwrap() + .into_compressed(); + + let alice_id = AccountId::new(alice_pk.clone(), Default::default()); + + // Create Bob's account + let bob_id = AccountId::new(bob_pk.clone(), Default::default()); + let bob_account = Account::create_with(bob_id.clone(), Balance::from_u64(500_000_000)); + ledger + .get_or_create_account(bob_id.clone(), bob_account) + .unwrap(); + + // Record initial state + let alice_location = ledger.location_of_account(&alice_id).unwrap(); + let alice_before = ledger.get(alice_location).unwrap(); + let bob_location = ledger.location_of_account(&bob_id).unwrap(); + let bob_before = ledger.get(bob_location).unwrap(); + let initial_alice_balance = alice_before.balance; + let initial_bob_balance = bob_before.balance; + + // Create a double fee transfer (5 MINA to Alice, 7 MINA to Bob) + let alice_fee = Fee::from_u64(5_000_000); + let bob_fee = Fee::from_u64(7_000_000); + let transfer1 = SingleFeeTransfer::create(alice_pk.clone(), alice_fee, TokenId::default()); + let transfer2 = SingleFeeTransfer::create(bob_pk.clone(), bob_fee, TokenId::default()); + let fee_transfer = FeeTransfer::of_singles(OneOrTwo::Two((transfer1, transfer2))).unwrap(); + + let constraint_constants = &test_constraint_constants(); + let state_view = ProtocolStateView { + snarked_ledger_hash: Fp::zero(), + blockchain_length: Length::from_u32(0), + min_window_density: Length::from_u32(0), + total_currency: Amount::zero(), + global_slot_since_genesis: Slot::from_u32(0), + staking_epoch_data: dummy_epoch_data(), + next_epoch_data: dummy_epoch_data(), + }; + let result = apply_transaction_first_pass( + constraint_constants, + Slot::from_u32(0), + &state_view, + &mut ledger, + &Transaction::FeeTransfer(fee_transfer), + ); + + assert!(result.is_ok()); + + // Verify ledger state changes + let alice_location = ledger.location_of_account(&alice_id).unwrap(); + let alice_after = ledger.get(alice_location).unwrap(); + let bob_location = ledger.location_of_account(&bob_id).unwrap(); + let bob_after = ledger.get(bob_location).unwrap(); + + // Verify Alice's balance increased by her fee amount + let expected_alice_balance = initial_alice_balance + .add_amount(Amount::of_fee(&alice_fee)) + .unwrap(); + assert_eq!( + alice_after.balance, expected_alice_balance, + "Alice's balance should increase by her fee amount" + ); + + // Verify Bob's balance increased by his fee amount + let expected_bob_balance = initial_bob_balance + .add_amount(Amount::of_fee(&bob_fee)) + .unwrap(); + assert_eq!( + bob_after.balance, expected_bob_balance, + "Bob's balance should increase by his fee amount" + ); + + // Verify nonces unchanged + assert_eq!( + alice_after.nonce, alice_before.nonce, + "Alice's nonce should remain unchanged" + ); + assert_eq!( + bob_after.nonce, bob_before.nonce, + "Bob's nonce should remain unchanged" + ); +} + +/// Test fee transfer to a nonexistent account. +/// +/// The receiver account does not exist, so the fee transfer should create it +/// with the transferred fee amount. +/// +/// Ledger state: New account created with the fee amount as balance. +#[test] +fn test_apply_fee_transfer_creates_account() { + let db = Database::create(15); + let mut ledger = Mask::new_root(db); + + let bob_pk = mina_signer::PubKey::from_address( + "B62qjVQLxt9nYMWGn45mkgwYfcz8e8jvjNCBo11VKJb7vxDNwv5QLPS", + ) + .unwrap() + .into_compressed(); + + let bob_id = AccountId::new(bob_pk.clone(), Default::default()); + + // Verify Bob's account does not exist before the transaction + assert!( + ledger.location_of_account(&bob_id).is_none(), + "Bob's account should not exist before transaction" + ); + + // Create a single fee transfer of 2 MINA to Bob (who doesn't exist yet) + // Must be >= account_creation_fee (1 MINA) to cover the creation cost + let fee = Fee::from_u64(2_000_000_000); + let single_transfer = SingleFeeTransfer::create(bob_pk.clone(), fee, TokenId::default()); + let fee_transfer = FeeTransfer::of_singles(OneOrTwo::One(single_transfer)).unwrap(); + + let constraint_constants = &test_constraint_constants(); + let state_view = ProtocolStateView { + snarked_ledger_hash: Fp::zero(), + blockchain_length: Length::from_u32(0), + min_window_density: Length::from_u32(0), + total_currency: Amount::zero(), + global_slot_since_genesis: Slot::from_u32(0), + staking_epoch_data: dummy_epoch_data(), + next_epoch_data: dummy_epoch_data(), + }; + let result = apply_transaction_first_pass( + constraint_constants, + Slot::from_u32(0), + &state_view, + &mut ledger, + &Transaction::FeeTransfer(fee_transfer), + ); + + assert!(result.is_ok()); + + // Verify Bob's account was created + let bob_location = ledger.location_of_account(&bob_id); + assert!( + bob_location.is_some(), + "Bob's account should exist after transaction" + ); + + // Verify Bob's balance equals the fee amount minus account creation fee + let bob_account = ledger.get(bob_location.unwrap()).unwrap(); + let account_creation_fee = constraint_constants.account_creation_fee; + let expected_balance = Balance::from_u64( + Amount::of_fee(&fee) + .as_u64() + .saturating_sub(account_creation_fee), + ); + assert_eq!( + bob_account.balance, expected_balance, + "Bob's balance should equal fee amount minus account creation fee" + ); + + // Verify Bob's nonce is 0 (new account) + assert_eq!( + bob_account.nonce, + Nonce::zero(), + "Bob's nonce should be 0 for new account" + ); +} + +/// Test fee transfer with incompatible tokens. +/// +/// A double fee transfer with different tokens should fail validation during +/// construction (FeeTransfer::of_singles). +/// +/// Ledger state: No transaction applied, ledger remains unchanged. +#[test] +fn test_apply_fee_transfer_incompatible_tokens() { + // Create two single transfers with different tokens + let alice_pk = mina_signer::PubKey::from_address( + "B62qmnY6m4c6bdgSPnQGZriSaj9vuSjsfh6qkveGTsFX3yGA5ywRaja", + ) + .unwrap() + .into_compressed(); + let bob_pk = mina_signer::PubKey::from_address( + "B62qjVQLxt9nYMWGn45mkgwYfcz8e8jvjNCBo11VKJb7vxDNwv5QLPS", + ) + .unwrap() + .into_compressed(); + + let alice_fee = Fee::from_u64(5_000_000); + let bob_fee = Fee::from_u64(7_000_000); + + // Create a custom token ID for the second transfer (different from default) + let custom_token = TokenId::from(999999u64); + + let transfer1 = SingleFeeTransfer::create(alice_pk.clone(), alice_fee, TokenId::default()); + let transfer2 = SingleFeeTransfer::create(bob_pk.clone(), bob_fee, custom_token); + + // Attempt to create a FeeTransfer with incompatible tokens + let result = FeeTransfer::of_singles(OneOrTwo::Two((transfer1, transfer2))); + + // Should fail with incompatible tokens error + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("Cannot combine single fee transfers with incompatible tokens")); +} diff --git a/ledger/tests/test_transaction_logic_first_pass_zkapp.rs b/ledger/tests/test_transaction_logic_first_pass_zkapp.rs new file mode 100644 index 000000000..b46026d04 --- /dev/null +++ b/ledger/tests/test_transaction_logic_first_pass_zkapp.rs @@ -0,0 +1,447 @@ +//! Tests for apply_transaction_first_pass with zkApp command transactions +//! +//! Run with: cargo test --test test_transaction_logic_first_pass_zkapp +//! +//! Tests the first pass of two-phase transaction application for zkApp +//! commands, covering: +//! - Successful zkApp command with single account update +//! - zkApp command with fee payer insufficient balance +//! - zkApp command with invalid fee payer nonce +//! - zkApp command from nonexistent fee payer + +use ark_ff::Zero; +use mina_core::constants::ConstraintConstants; +use mina_curves::pasta::Fp; +use mina_signer::Signature; +use mina_tree::{ + scan_state::{ + currency::{Amount, Balance, Fee, Length, Magnitude, Nonce, Sgn, Signed, Slot}, + transaction_logic::{ + protocol_state::{EpochData, EpochLedger, ProtocolStateView}, + transaction_partially_applied::apply_transaction_first_pass, + zkapp_command::{ + Account, AccountPreconditions, AccountUpdate, Actions, AuthorizationKind, Body, + CallForest, Control, Events, FeePayer, FeePayerBody, MayUseToken, Numeric, + Preconditions, Tree, Update, WithStackHash, ZkAppCommand, ZkAppPreconditions, + }, + Memo, Transaction, UserCommand, + }, + }, + Account as LedgerAccount, AccountId, BaseLedger, Database, Mask, MutableFp, TokenId, +}; + +fn dummy_epoch_data() -> EpochData { + EpochData { + ledger: EpochLedger { + hash: Fp::zero(), + total_currency: Amount::zero(), + }, + seed: Fp::zero(), + start_checkpoint: Fp::zero(), + lock_checkpoint: Fp::zero(), + epoch_length: Length::from_u32(0), + } +} + +fn test_constraint_constants() -> ConstraintConstants { + ConstraintConstants { + sub_windows_per_window: 11, + ledger_depth: 15, + work_delay: 2, + block_window_duration_ms: 180_000, + transaction_capacity_log_2: 7, + pending_coinbase_depth: 5, + coinbase_amount: 720_000_000_000, + supercharged_coinbase_factor: 2, + account_creation_fee: 1_000_000_000, + fork: None, + } +} + +fn create_test_ledger() -> Mask { + let db = Database::create(15); + let mut ledger = Mask::new_root(db); + let alice = mina_signer::PubKey::from_address( + "B62qmnY6m4c6bdgSPnQGZriSaj9vuSjsfh6qkveGTsFX3yGA5ywRaja", + ) + .unwrap() + .into_compressed(); + let bob = mina_signer::PubKey::from_address( + "B62qjVQLxt9nYMWGn45mkgwYfcz8e8jvjNCBo11VKJb7vxDNwv5QLPS", + ) + .unwrap() + .into_compressed(); + + // Create Alice's account with balance + let alice_id = AccountId::new(alice, Default::default()); + let alice_account = + LedgerAccount::create_with(alice_id.clone(), Balance::from_u64(1_000_000_000)); + ledger + .get_or_create_account(alice_id, alice_account) + .unwrap(); + + // Create Bob's account + let bob_id = AccountId::new(bob, Default::default()); + let bob_account = LedgerAccount::create_with(bob_id.clone(), Balance::from_u64(500_000_000)); + ledger.get_or_create_account(bob_id, bob_account).unwrap(); + + ledger +} + +fn create_simple_zkapp_command( + fee_payer_pk: &mina_signer::CompressedPubKey, + account_update_pk: &mina_signer::CompressedPubKey, + fee: u64, + nonce: u32, +) -> ZkAppCommand { + // Create fee payer body + let fee_payer_body = FeePayerBody { + public_key: fee_payer_pk.clone(), + fee: Fee::from_u64(fee), + valid_until: Some(Slot::max()), + nonce: Nonce::from_u32(nonce), + }; + + // Create fee payer with a dummy signature + let fee_payer = FeePayer { + body: fee_payer_body, + authorization: Signature::dummy(), + }; + + // Create an account update body with no balance change + let account_update_body = Body { + public_key: account_update_pk.clone(), + token_id: TokenId::default(), + update: Update::noop(), + balance_change: Signed { + magnitude: Amount::zero(), + sgn: Sgn::Pos, + }, + increment_nonce: false, + events: Events::empty(), + actions: Actions::empty(), + call_data: Fp::zero(), + preconditions: Preconditions { + network: ZkAppPreconditions::accept(), + account: AccountPreconditions(Account::accept()), + valid_while: Numeric::Ignore, + }, + use_full_commitment: false, + implicit_account_creation_fee: false, + may_use_token: MayUseToken::No, + authorization_kind: AuthorizationKind::NoneGiven, + }; + + // Create account update + let account_update = AccountUpdate { + body: account_update_body, + authorization: Control::NoneGiven, + }; + + // Create a tree with the account update and empty calls + let tree = Tree { + account_update, + account_update_digest: MutableFp::new(Fp::zero()), + calls: CallForest::new(), + }; + + // Wrap tree in WithStackHash + let tree_with_hash = WithStackHash { + elt: tree, + stack_hash: MutableFp::new(Fp::zero()), + }; + + // Create call forest with the tree + let call_forest = CallForest(vec![tree_with_hash]); + + // Ensure hashes are computed + call_forest.ensure_hashed(); + + // Create the zkApp command + ZkAppCommand { + fee_payer, + account_updates: call_forest, + memo: Memo::empty(), + } +} + +#[test] +fn test_apply_zkapp_command_success() { + let mut ledger = create_test_ledger(); + + let alice_pk = mina_signer::PubKey::from_address( + "B62qmnY6m4c6bdgSPnQGZriSaj9vuSjsfh6qkveGTsFX3yGA5ywRaja", + ) + .unwrap() + .into_compressed(); + let bob_pk = mina_signer::PubKey::from_address( + "B62qjVQLxt9nYMWGn45mkgwYfcz8e8jvjNCBo11VKJb7vxDNwv5QLPS", + ) + .unwrap() + .into_compressed(); + + let alice_id = AccountId::new(alice_pk.clone(), Default::default()); + + // Record initial state + let alice_location = ledger.location_of_account(&alice_id).unwrap(); + let alice_before = ledger.get(alice_location).unwrap(); + let initial_alice_balance = alice_before.balance; + let initial_alice_nonce = alice_before.nonce; + + let fee = 10_000_000; + let nonce = 0; + let zkapp_command = create_simple_zkapp_command(&alice_pk, &bob_pk, fee, nonce); + + let constraint_constants = &test_constraint_constants(); + let state_view = ProtocolStateView { + snarked_ledger_hash: Fp::zero(), + blockchain_length: Length::from_u32(0), + min_window_density: Length::from_u32(0), + total_currency: Amount::zero(), + global_slot_since_genesis: Slot::from_u32(0), + staking_epoch_data: dummy_epoch_data(), + next_epoch_data: dummy_epoch_data(), + }; + let result = apply_transaction_first_pass( + constraint_constants, + Slot::from_u32(0), + &state_view, + &mut ledger, + &Transaction::Command(UserCommand::ZkAppCommand(Box::new(zkapp_command))), + ); + + assert!(result.is_ok()); + + // Verify ledger state changes + let alice_location = ledger.location_of_account(&alice_id).unwrap(); + let alice_after = ledger.get(alice_location).unwrap(); + + // Verify Alice's balance decreased by fee + let expected_alice_balance = initial_alice_balance + .sub_amount(Amount::from_u64(fee)) + .unwrap(); + assert_eq!( + alice_after.balance, expected_alice_balance, + "Alice's balance should decrease by fee" + ); + + // Verify Alice's nonce incremented + assert_eq!( + alice_after.nonce, + initial_alice_nonce.incr(), + "Alice's nonce should be incremented" + ); +} + +/// Test zkApp command with insufficient balance for fee. +/// +/// The transaction is rejected during fee payment validation because the +/// fee payer balance is less than the required fee. +/// +/// Ledger state: Remains unchanged (no fee charged, no nonce incremented). +#[test] +fn test_apply_zkapp_command_insufficient_balance() { + let db = Database::create(15); + let mut ledger = Mask::new_root(db); + + let alice_pk = mina_signer::PubKey::from_address( + "B62qmnY6m4c6bdgSPnQGZriSaj9vuSjsfh6qkveGTsFX3yGA5ywRaja", + ) + .unwrap() + .into_compressed(); + let bob_pk = mina_signer::PubKey::from_address( + "B62qjVQLxt9nYMWGn45mkgwYfcz8e8jvjNCBo11VKJb7vxDNwv5QLPS", + ) + .unwrap() + .into_compressed(); + + // Create Alice's account with very small balance + let alice_id = AccountId::new(alice_pk.clone(), Default::default()); + let alice_account = LedgerAccount::create_with(alice_id.clone(), Balance::from_u64(1_000_000)); + ledger + .get_or_create_account(alice_id.clone(), alice_account) + .unwrap(); + + // Create Bob's account + let bob_id = AccountId::new(bob_pk.clone(), Default::default()); + let bob_account = LedgerAccount::create_with(bob_id.clone(), Balance::from_u64(500_000_000)); + ledger.get_or_create_account(bob_id, bob_account).unwrap(); + + // Record initial state + let alice_location = ledger.location_of_account(&alice_id).unwrap(); + let alice_before = ledger.get(alice_location).unwrap(); + let initial_alice_balance = alice_before.balance; + let initial_alice_nonce = alice_before.nonce; + + let fee = 10_000_000; // More than Alice's balance + let nonce = 0; + let zkapp_command = create_simple_zkapp_command(&alice_pk, &bob_pk, fee, nonce); + + let constraint_constants = &test_constraint_constants(); + let state_view = ProtocolStateView { + snarked_ledger_hash: Fp::zero(), + blockchain_length: Length::from_u32(0), + min_window_density: Length::from_u32(0), + total_currency: Amount::zero(), + global_slot_since_genesis: Slot::from_u32(0), + staking_epoch_data: dummy_epoch_data(), + next_epoch_data: dummy_epoch_data(), + }; + let result = apply_transaction_first_pass( + constraint_constants, + Slot::from_u32(0), + &state_view, + &mut ledger, + &Transaction::Command(UserCommand::ZkAppCommand(Box::new(zkapp_command))), + ); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "[[Overflow]]"); + + // Verify ledger state unchanged + let alice_location = ledger.location_of_account(&alice_id).unwrap(); + let alice_after = ledger.get(alice_location).unwrap(); + assert_eq!( + alice_after.balance, initial_alice_balance, + "Alice's balance should remain unchanged" + ); + assert_eq!( + alice_after.nonce, initial_alice_nonce, + "Alice's nonce should remain unchanged" + ); +} + +/// Test zkApp command with incorrect nonce. +/// +/// The transaction is rejected during fee payment validation because the +/// provided nonce does not match the fee payer's current nonce. +/// +/// Ledger state: Remains unchanged (no fee charged, no nonce incremented). +#[test] +fn test_apply_zkapp_command_invalid_nonce() { + let mut ledger = create_test_ledger(); + + let alice_pk = mina_signer::PubKey::from_address( + "B62qmnY6m4c6bdgSPnQGZriSaj9vuSjsfh6qkveGTsFX3yGA5ywRaja", + ) + .unwrap() + .into_compressed(); + let bob_pk = mina_signer::PubKey::from_address( + "B62qjVQLxt9nYMWGn45mkgwYfcz8e8jvjNCBo11VKJb7vxDNwv5QLPS", + ) + .unwrap() + .into_compressed(); + + let alice_id = AccountId::new(alice_pk.clone(), Default::default()); + + // Record initial state + let alice_location = ledger.location_of_account(&alice_id).unwrap(); + let alice_before = ledger.get(alice_location).unwrap(); + let initial_alice_balance = alice_before.balance; + let initial_alice_nonce = alice_before.nonce; + + let fee = 10_000_000; + let nonce = 5; // Wrong nonce (should be 0) + let zkapp_command = create_simple_zkapp_command(&alice_pk, &bob_pk, fee, nonce); + + let constraint_constants = &test_constraint_constants(); + let state_view = ProtocolStateView { + snarked_ledger_hash: Fp::zero(), + blockchain_length: Length::from_u32(0), + min_window_density: Length::from_u32(0), + total_currency: Amount::zero(), + global_slot_since_genesis: Slot::from_u32(0), + staking_epoch_data: dummy_epoch_data(), + next_epoch_data: dummy_epoch_data(), + }; + let result = apply_transaction_first_pass( + constraint_constants, + Slot::from_u32(0), + &state_view, + &mut ledger, + &Transaction::Command(UserCommand::ZkAppCommand(Box::new(zkapp_command))), + ); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "[[AccountNoncePreconditionUnsatisfied]]" + ); + + // Verify ledger state unchanged + let alice_location = ledger.location_of_account(&alice_id).unwrap(); + let alice_after = ledger.get(alice_location).unwrap(); + assert_eq!( + alice_after.balance, initial_alice_balance, + "Alice's balance should remain unchanged" + ); + assert_eq!( + alice_after.nonce, initial_alice_nonce, + "Alice's nonce should remain unchanged" + ); +} + +/// Test zkApp command from a nonexistent fee payer account. +/// +/// The transaction is rejected during fee payment validation because the +/// fee payer account does not exist in the ledger. +/// +/// Ledger state: Remains unchanged (no new account created). +#[test] +fn test_apply_zkapp_command_nonexistent_fee_payer() { + let db = Database::create(15); + let mut ledger = Mask::new_root(db); + + let alice_pk = mina_signer::PubKey::from_address( + "B62qmnY6m4c6bdgSPnQGZriSaj9vuSjsfh6qkveGTsFX3yGA5ywRaja", + ) + .unwrap() + .into_compressed(); + let bob_pk = mina_signer::PubKey::from_address( + "B62qjVQLxt9nYMWGn45mkgwYfcz8e8jvjNCBo11VKJb7vxDNwv5QLPS", + ) + .unwrap() + .into_compressed(); + + let alice_id = AccountId::new(alice_pk.clone(), Default::default()); + + // Verify Alice's account does not exist before the transaction + assert!( + ledger.location_of_account(&alice_id).is_none(), + "Alice's account should not exist before transaction" + ); + + let fee = 10_000_000; + let nonce = 0; + let zkapp_command = create_simple_zkapp_command(&alice_pk, &bob_pk, fee, nonce); + + let constraint_constants = &test_constraint_constants(); + let state_view = ProtocolStateView { + snarked_ledger_hash: Fp::zero(), + blockchain_length: Length::from_u32(0), + min_window_density: Length::from_u32(0), + total_currency: Amount::zero(), + global_slot_since_genesis: Slot::from_u32(0), + staking_epoch_data: dummy_epoch_data(), + next_epoch_data: dummy_epoch_data(), + }; + let result = apply_transaction_first_pass( + constraint_constants, + Slot::from_u32(0), + &state_view, + &mut ledger, + &Transaction::Command(UserCommand::ZkAppCommand(Box::new(zkapp_command))), + ); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "[[Overflow, AmountInsufficientToCreateAccount]]" + ); + + // Verify Alice's account still does not exist after the error + assert!( + ledger.location_of_account(&alice_id).is_none(), + "Alice's account should still not exist after transaction error" + ); +}