Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,9 @@ pub struct FailureCollection {
inner: Vec<Vec<TransactionFailure>>,
}

/// <https://github.com/MinaProtocol/mina/blob/bfd1009abdbee78979ff0343cc73a3480e862f58/src/lib/transaction_logic/mina_transaction_logic.ml#L2197C1-L2210C53>
/// OCaml reference: src/lib/transaction_logic/mina_transaction_logic.ml L:2197-2210
/// Commit: bfd1009abdbee78979ff0343cc73a3480e862f58
/// Last verified: 2025-10-16
impl FailureCollection {
fn empty() -> Self {
Self {
Expand Down Expand Up @@ -440,17 +442,63 @@ impl FailureCollection {
}
}

/// Structure of the failure status:
/// I. No fee transfer and coinbase transfer fails: `[[failure]]`
/// II. With fee transfer-
/// Both fee transfer and coinbase fails:
/// `[[failure-of-fee-transfer]; [failure-of-coinbase]]`
/// Fee transfer succeeds and coinbase fails:
/// `[[];[failure-of-coinbase]]`
/// Fee transfer fails and coinbase succeeds:
/// `[[failure-of-fee-transfer];[]]`
/// Applies a coinbase transaction to the ledger.
///
/// Processes the coinbase by first applying the optional fee transfer (if present),
/// then applying the coinbase reward to the receiver. Updates account balances and
/// timing, creates accounts if needed, and handles permission checks.
///
/// # Implementation Notes
///
/// - When `coinbase.fee_transfer` is `Some`, processes fee transfer first, then
/// coinbase receiver gets `coinbase.amount - fee_transfer.fee`
/// - When `coinbase.fee_transfer` is `None`, coinbase receiver gets full
/// `coinbase.amount`
/// - Calls `has_permission_to_receive` for each recipient
/// - Calls `sub_account_creation_fee` when creating new accounts
/// - Calls `update_timing_when_no_deduction` for timing validation
/// - Only updates coinbase receiver timing when `fee_transfer` is `None`
/// - Uses `FailureCollection` to track which operations succeeded/failed
///
/// # Parameters
///
/// - `constraint_constants`: Protocol constants including account creation fees
/// - `txn_global_slot`: Current global slot for timing validation
/// - `ledger`: Mutable ledger to update
/// - `coinbase`: Coinbase transaction containing receiver, amount, and optional
/// fee transfer
///
/// # Returns
///
/// Returns [`CoinbaseApplied`] containing:
/// - `coinbase`: The input coinbase with transaction status
/// - `new_accounts`: Vector of newly created account IDs
/// - `burned_tokens`: Amount of tokens burned from failed transfers
///
/// # Errors
///
/// Returns `Err` if:
/// - `fee_transfer.fee` exceeds `coinbase.amount` (checked subtraction fails)
/// - Burned tokens overflow when summing across transfers
///
/// For protocol-level documentation and behavioral specification, see:
/// <https://o1-labs.github.io/mina-rust/docs/developers/transactions/coinbase>
///
/// # Tests
///
/// Test coverage (in `ledger/tests/test_transaction_logic_first_pass_coinbase.rs`):
///
/// - `test_apply_coinbase_without_fee_transfer`
/// - `test_apply_coinbase_with_fee_transfer`
/// - `test_apply_coinbase_with_fee_transfer_creates_account`
/// - `test_apply_coinbase_with_fee_transfer_to_same_account`
/// - `test_apply_coinbase_creates_account`
///
/// # OCaml Reference
///
/// <https://github.com/MinaProtocol/mina/blob/2ee6e004ba8c6a0541056076aab22ea162f7eb3a/src/lib/transaction_logic/mina_transaction_logic.ml#L2022>
/// OCaml reference: src/lib/transaction_logic/mina_transaction_logic.ml L:2074-2178
/// Commit: 0063f0196d046d9d2fc8af0cea76ff30f51b49b7
/// Last verified: 2025-10-16
pub fn apply_coinbase<L>(
constraint_constants: &ConstraintConstants,
txn_global_slot: &Slot,
Expand Down Expand Up @@ -649,7 +697,9 @@ where
})
}

/// <https://github.com/MinaProtocol/mina/blob/2ee6e004ba8c6a0541056076aab22ea162f7eb3a/src/lib/transaction_logic/mina_transaction_logic.ml#L607>
/// OCaml reference: src/lib/transaction_logic/mina_transaction_logic.ml L:607
/// Commit: 2ee6e004ba8c6a0541056076aab22ea162f7eb3a
/// Last verified: 2025-10-16
fn sub_account_creation_fee(
constraint_constants: &ConstraintConstants,
action: AccountState,
Expand Down Expand Up @@ -862,7 +912,9 @@ pub enum AccountState {
#[derive(Debug)]
struct HasPermissionToReceive(bool);

/// <https://github.com/MinaProtocol/mina/blob/2ee6e004ba8c6a0541056076aab22ea162f7eb3a/src/lib/transaction_logic/mina_transaction_logic.ml#L1852>
/// OCaml reference: src/lib/transaction_logic/mina_transaction_logic.ml L:1852
/// Commit: 2ee6e004ba8c6a0541056076aab22ea162f7eb3a
/// Last verified: 2025-10-16
fn has_permission_to_receive<L>(
ledger: &mut L,
receiver_account_id: &AccountId,
Expand Down Expand Up @@ -1151,7 +1203,9 @@ where
}),
Err(failure) => {
// This case occurs when an exception is throwned in OCaml
// <https://github.com/MinaProtocol/mina/blob/3753a8593cc1577bcf4da16620daf9946d88e8e5/src/lib/transaction_logic/mina_transaction_logic.ml#L964>
// OCaml reference: src/lib/transaction_logic/mina_transaction_logic.ml L:964
// Commit: 3753a8593cc1577bcf4da16620daf9946d88e8e5
// Last verified: 2025-10-16
assert!(reject_command);
Err(failure.to_string())
}
Expand Down
125 changes: 125 additions & 0 deletions ledger/tests/test_transaction_logic_first_pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//!
//! Tests the first pass of two-phase transaction application, covering:
//! - Successful payment transactions
//! - Payment creating receiver account
//! - Insufficient balance errors
//! - Invalid nonce errors
//! - Nonexistent fee payer errors
Expand Down Expand Up @@ -439,3 +440,127 @@ fn test_apply_payment_nonexistent_fee_payer() {
"Alice's account should still not exist after transaction error"
);
}

/// Test payment that creates a new receiver account.
///
/// When the receiver account doesn't exist, a new account is created
/// automatically. The account creation fee is deducted from the payment amount,
/// not from the sender's balance.
///
/// Ledger state: Sender's balance decreased by amount + fee, receiver account
/// created with balance = amount - account_creation_fee.
#[test]
fn test_apply_payment_creates_receiver_account() {
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 only Alice's account
let alice_id = AccountId::new(alice_pk.clone(), Default::default());
let alice_account = Account::create_with(alice_id.clone(), Balance::from_u64(5_000_000_000));
ledger
.get_or_create_account(alice_id.clone(), alice_account)
.unwrap();

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"
);

// 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_receipt_hash = alice_before.receipt_chain_hash;

let amount = 2_000_000_000; // 2 MINA
let fee = 10_000_000; // 0.01 MINA
let nonce = 0;
let payment = create_payment(&alice_pk, &bob_pk, amount, fee, nonce);

let constraint_constants = &test_constraint_constants();
let account_creation_fee = constraint_constants.account_creation_fee; // 1 MINA

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(payment))),
);

assert!(result.is_ok());

// Verify Alice's balance decreased by fee + payment amount
let alice_location = ledger.location_of_account(&alice_id).unwrap();
let alice_after = ledger.get(alice_location).unwrap();
let expected_alice_balance = initial_alice_balance
.sub_amount(Amount::from_u64(fee))
.unwrap()
.sub_amount(Amount::from_u64(amount))
.unwrap();
assert_eq!(
alice_after.balance, expected_alice_balance,
"Alice's balance should decrease by fee + payment amount"
);

// 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 Bob's account was created
let bob_location = ledger.location_of_account(&bob_id);
assert!(
bob_location.is_some(),
"Bob's account should now exist after transaction"
);

// Verify Bob's balance is payment amount minus account creation fee
let bob_location = bob_location.unwrap();
let bob_after = ledger.get(bob_location).unwrap();
let expected_bob_balance =
Balance::from_u64(amount - account_creation_fee);
assert_eq!(
bob_after.balance, expected_bob_balance,
"Bob's balance should be payment amount minus account creation fee"
);

// Verify Bob's nonce is 0 (new account)
assert_eq!(
bob_after.nonce,
Nonce::zero(),
"Bob's nonce should be 0 for new account"
);
}
117 changes: 116 additions & 1 deletion ledger/tests/test_transaction_logic_first_pass_coinbase.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
//! Tests for apply_transaction_first_pass with coinbase transactions
//!
//! Run with: cargo test --test test_transaction_logic_first_pass_coinbase
//! Run with: cargo test --test test_transaction_logic_first_pass_coinbase --release
//!
//! 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 nonexistent account (creates account)
//! - Coinbase with fee transfer to same account (fee transfer should be
//! removed)
//! - Coinbase creating a new account
Expand Down Expand Up @@ -228,6 +229,120 @@ fn test_apply_coinbase_with_fee_transfer() {
);
}

/// Test coinbase with fee transfer to a nonexistent account.
///
/// The coinbase receiver exists, but the fee transfer receiver doesn't exist.
/// The fee transfer should create the receiver account, deducting the account
/// creation fee from the fee transfer amount.
///
/// Ledger state:
/// - Coinbase receiver gets coinbase_amount - fee_transfer_amount
/// - Fee transfer receiver account created with fee_transfer_amount -
/// account_creation_fee
#[test]
fn test_apply_coinbase_with_fee_transfer_creates_account() {
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());

// 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"
);

// Record Alice's 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 to Bob
// (who doesn't exist yet)
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 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 ledger state changes
let alice_location = ledger.location_of_account(&alice_id).unwrap();
let alice_after = ledger.get(alice_location).unwrap();
let bob_account = ledger.get(bob_location.unwrap()).unwrap();

// Verify Alice's balance increased by (coinbase amount - fee transfer amount)
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 equals fee transfer amount minus account creation fee
let account_creation_fee = constraint_constants.account_creation_fee;
let expected_bob_balance = Balance::from_u64(
Amount::of_fee(&fee_transfer_amount)
.as_u64()
.saturating_sub(account_creation_fee),
);
assert_eq!(
bob_account.balance, expected_bob_balance,
"Bob's balance should equal fee transfer minus account creation fee"
);

// Verify nonces
assert_eq!(
alice_after.nonce, alice_before.nonce,
"Alice's nonce should remain unchanged"
);
assert_eq!(
bob_account.nonce,
Nonce::zero(),
"Bob's nonce should be 0 for new account"
);
}

/// Test coinbase with fee transfer to the same account.
///
/// When the coinbase receiver and fee transfer receiver are the same, the fee
Expand Down
Loading
Loading