diff --git a/rs/rosetta-api/icrc1/ledger/sm-tests/BUILD.bazel b/rs/rosetta-api/icrc1/ledger/sm-tests/BUILD.bazel index c1b22aa0b6b..f90be116f6a 100644 --- a/rs/rosetta-api/icrc1/ledger/sm-tests/BUILD.bazel +++ b/rs/rosetta-api/icrc1/ledger/sm-tests/BUILD.bazel @@ -1,7 +1,39 @@ -load("@rules_rust//rust:defs.bzl", "rust_library") +load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") package(default_visibility = ["//visibility:public"]) +DEPENDENCIES = [ + # Keep sorted. + "//packages/ic-ledger-hash-of:ic_ledger_hash_of", + "//packages/icrc-ledger-types:icrc_ledger_types", + "//rs/rosetta-api/icrc1", + "//rs/rosetta-api/icrc1/ledger", + "//rs/rosetta-api/ledger_canister_core", + "//rs/rosetta-api/ledger_core", + "//rs/rust_canisters/http_types", + "//rs/state_machine_tests", + "//rs/types/base_types", + "//rs/types/error_types", + "//rs/types/management_canister_types", + "//rs/types/types", + "//rs/universal_canister/lib", + "@crate_index//:anyhow", + "@crate_index//:candid", + "@crate_index//:cddl", + "@crate_index//:futures", + "@crate_index//:hex", + "@crate_index//:icrc1-test-env", + "@crate_index//:icrc1-test-suite", + "@crate_index//:num-traits", + "@crate_index//:proptest", + "@crate_index//:serde", +] + +MACRO_DEPENDENCIES = [ + # Keep sorted. + "@crate_index//:async-trait", +] + [ rust_library( name = "sm-tests" + name_suffix, @@ -14,37 +46,30 @@ package(default_visibility = ["//visibility:public"]) data = [ "//rs/rosetta-api/icrc1/ledger:block.cddl", ], - proc_macro_deps = [ - # Keep sorted. - "@crate_index//:async-trait", - ], + proc_macro_deps = MACRO_DEPENDENCIES, version = "0.9.0", - deps = [ - # Keep sorted. - "//packages/ic-ledger-hash-of:ic_ledger_hash_of", - "//packages/icrc-ledger-types:icrc_ledger_types", - "//rs/rosetta-api/icrc1", - "//rs/rosetta-api/icrc1/ledger", - "//rs/rosetta-api/ledger_canister_core", - "//rs/rosetta-api/ledger_core", - "//rs/rust_canisters/http_types", - "//rs/state_machine_tests", - "//rs/types/base_types", - "//rs/types/error_types", - "//rs/types/management_canister_types", - "//rs/types/types", - "//rs/universal_canister/lib", - "@crate_index//:anyhow", - "@crate_index//:candid", - "@crate_index//:cddl", - "@crate_index//:futures", - "@crate_index//:hex", - "@crate_index//:icrc1-test-env", - "@crate_index//:icrc1-test-suite", - "@crate_index//:num-traits", - "@crate_index//:proptest", - "@crate_index//:serde", - ] + extra_deps, + deps = DEPENDENCIES + extra_deps, + ) + for (name_suffix, features, extra_deps) in [ + ( + "", + [], + ["//rs/rosetta-api/icrc1/tokens_u64"], + ), + ( + "_u256", + ["u256-tokens"], + ["//rs/rosetta-api/icrc1/tokens_u256"], + ), + ] +] + +[ + rust_test( + name = "sm-tests-unit-tests" + name_suffix, + crate = ":sm-tests" + name_suffix, + crate_features = features, + deps = DEPENDENCIES + extra_deps, ) for (name_suffix, features, extra_deps) in [ ( diff --git a/rs/rosetta-api/icrc1/ledger/sm-tests/src/in_memory_ledger.rs b/rs/rosetta-api/icrc1/ledger/sm-tests/src/in_memory_ledger.rs new file mode 100644 index 00000000000..f5a31cda716 --- /dev/null +++ b/rs/rosetta-api/icrc1/ledger/sm-tests/src/in_memory_ledger.rs @@ -0,0 +1,437 @@ +use super::{get_all_ledger_and_archive_blocks, get_allowance, Tokens}; +use crate::metrics::parse_metric; +use candid::{Decode, Encode, Nat}; +use ic_base_types::CanisterId; +use ic_icrc1::Operation; +use ic_ledger_core::approvals::Allowance; +use ic_ledger_core::timestamp::TimeStamp; +use ic_ledger_core::tokens::{TokensType, Zero}; +use ic_state_machine_tests::StateMachine; +use icrc_ledger_types::icrc1::account::Account; +use std::collections::HashMap; +use std::hash::Hash; + +#[cfg(test)] +mod tests; + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)] +pub struct ApprovalKey(Account, Account); + +impl From<(&Account, &Account)> for ApprovalKey { + fn from((account, spender): (&Account, &Account)) -> Self { + Self(*account, *spender) + } +} + +impl From for (Account, Account) { + fn from(key: ApprovalKey) -> Self { + (key.0, key.1) + } +} + +trait InMemoryLedgerState { + type AccountId; + type Tokens; + + fn process_approve( + &mut self, + from: &Self::AccountId, + spender: &Self::AccountId, + amount: &Self::Tokens, + expected_allowance: &Option, + expires_at: &Option, + fee: &Option, + now: TimeStamp, + ); + fn process_burn( + &mut self, + from: &Self::AccountId, + spender: &Option, + amount: &Self::Tokens, + ); + fn process_mint(&mut self, to: &Self::AccountId, amount: &Self::Tokens); + fn process_transfer( + &mut self, + from: &Self::AccountId, + to: &Self::AccountId, + spender: &Option, + amount: &Self::Tokens, + fee: &Option, + ); + fn validate_invariants(&self); +} + +pub struct InMemoryLedger +where + K: Ord, +{ + pub balances: HashMap, + pub allowances: HashMap>, + pub total_supply: Tokens, + pub fee_collector: Option, +} + +impl InMemoryLedgerState for InMemoryLedger +where + K: Ord + for<'a> From<(&'a AccountId, &'a AccountId)> + Clone + Hash, + K: Into<(AccountId, AccountId)>, + AccountId: PartialEq + Ord + Clone + Hash + std::fmt::Debug, + Tokens: TokensType + Default, +{ + type AccountId = AccountId; + type Tokens = Tokens; + + fn process_approve( + &mut self, + from: &Self::AccountId, + spender: &Self::AccountId, + amount: &Self::Tokens, + expected_allowance: &Option, + expires_at: &Option, + fee: &Option, + now: TimeStamp, + ) { + self.burn_fee(from, fee); + self.set_allowance(from, spender, amount, expected_allowance, expires_at, now); + } + + fn process_burn( + &mut self, + from: &Self::AccountId, + spender: &Option, + amount: &Self::Tokens, + ) { + self.decrease_balance(from, amount); + self.decrease_total_supply(amount); + if let Some(spender) = spender { + if from != spender { + self.decrease_allowance(from, spender, amount, None); + } + } + } + + fn process_mint(&mut self, to: &Self::AccountId, amount: &Self::Tokens) { + self.increase_balance(to, amount); + self.increase_total_supply(amount); + } + + fn process_transfer( + &mut self, + from: &Self::AccountId, + to: &Self::AccountId, + spender: &Option, + amount: &Self::Tokens, + fee: &Option, + ) { + self.decrease_balance(from, amount); + self.collect_fee(from, fee); + if let Some(fee) = fee { + if let Some(spender) = spender { + if from != spender { + self.decrease_allowance(from, spender, amount, Some(fee)); + } + } + } + self.increase_balance(to, amount); + } + + fn validate_invariants(&self) { + let mut balances_total = Self::Tokens::default(); + for amount in self.balances.values() { + balances_total = balances_total.checked_add(amount).unwrap(); + assert_ne!(amount, &Tokens::zero()); + } + assert_eq!(self.total_supply, balances_total); + for allowance in self.allowances.values() { + assert_ne!(&allowance.amount, &Tokens::zero()); + } + } +} + +impl Default for InMemoryLedger +where + K: Ord + for<'a> From<(&'a AccountId, &'a AccountId)> + Clone + Hash, + K: Into<(AccountId, AccountId)>, + AccountId: PartialEq + Ord + Clone + Hash, + Tokens: TokensType, +{ + fn default() -> Self { + InMemoryLedger { + balances: HashMap::new(), + allowances: HashMap::new(), + total_supply: Tokens::zero(), + fee_collector: None, + } + } +} + +impl InMemoryLedger +where + K: Ord + for<'a> From<(&'a AccountId, &'a AccountId)> + Clone + Hash, + K: Into<(AccountId, AccountId)>, + AccountId: PartialEq + Ord + Clone + Hash, + Tokens: TokensType, +{ + fn decrease_allowance( + &mut self, + from: &AccountId, + spender: &AccountId, + amount: &Tokens, + fee: Option<&Tokens>, + ) { + let key = K::from((from, spender)); + let old_allowance = self + .allowances + .get(&key) + .unwrap_or_else(|| panic!("Allowance not found",)); + let mut new_allowance_value = old_allowance + .amount + .checked_sub(amount) + .unwrap_or_else(|| panic!("Insufficient allowance",)); + if let Some(fee) = fee { + new_allowance_value = new_allowance_value + .checked_sub(fee) + .unwrap_or_else(|| panic!("Insufficient allowance",)); + } + if new_allowance_value.is_zero() { + self.allowances.remove(&key); + } else { + self.allowances.insert( + key, + Allowance { + amount: new_allowance_value, + expires_at: old_allowance.expires_at, + arrived_at: old_allowance.arrived_at, + }, + ); + } + } + + fn decrease_balance(&mut self, from: &AccountId, amount: &Tokens) { + let old_balance = self + .balances + .get(from) + .unwrap_or_else(|| panic!("Account not found",)); + let new_balance = old_balance + .checked_sub(amount) + .unwrap_or_else(|| panic!("Insufficient balance",)); + if new_balance.is_zero() { + self.balances.remove(from); + } else { + self.balances.insert(from.clone(), new_balance); + } + } + + fn decrease_total_supply(&mut self, amount: &Tokens) { + self.total_supply = self + .total_supply + .checked_sub(amount) + .unwrap_or_else(|| panic!("Total supply underflow",)); + } + + fn set_allowance( + &mut self, + from: &AccountId, + spender: &AccountId, + amount: &Tokens, + expected_allowance: &Option, + expires_at: &Option, + arrived_at: TimeStamp, + ) { + let key = K::from((from, spender)); + if let Some(expected_allowance) = expected_allowance { + let current_allowance = self + .allowances + .get(&key) + .unwrap_or_else(|| panic!("No current allowance but expected allowance set")); + if current_allowance.amount != *expected_allowance { + panic!("Expected allowance does not match current allowance"); + } + } + if amount == &Tokens::zero() { + self.allowances.remove(&key); + } else { + self.allowances.insert( + key, + Allowance { + amount: amount.clone(), + expires_at: expires_at.map(TimeStamp::from_nanos_since_unix_epoch), + arrived_at, + }, + ); + } + } + + fn increase_balance(&mut self, to: &AccountId, amount: &Tokens) { + let new_balance = match self.balances.get(to) { + None => amount.clone(), + Some(old_balance) => old_balance + .checked_add(amount) + .unwrap_or_else(|| panic!("Balance overflow")), + }; + if !new_balance.is_zero() { + self.balances.insert(to.clone(), new_balance); + } + } + + fn increase_total_supply(&mut self, amount: &Tokens) { + self.total_supply = self + .total_supply + .checked_add(amount) + .unwrap_or_else(|| panic!("Total supply overflow")); + } + + fn collect_fee(&mut self, from: &AccountId, amount: &Option) { + if let Some(amount) = amount { + self.decrease_balance(from, amount); + if let Some(fee_collector) = &self.fee_collector { + self.increase_balance(&fee_collector.clone(), amount); + } else { + self.decrease_total_supply(amount); + } + } + } + + fn burn_fee(&mut self, from: &AccountId, amount: &Option) { + if let Some(amount) = amount { + self.decrease_balance(from, amount); + self.decrease_total_supply(amount); + } + } + + fn prune_expired_allowances(&mut self, now: TimeStamp) { + let expired_allowances: Vec = self + .allowances + .iter() + .filter_map(|(key, allowance)| { + if let Some(expires_at) = allowance.expires_at { + if now >= expires_at { + return Some(key.clone()); + } + } + None + }) + .collect(); + for key in expired_allowances { + self.allowances.remove(&key); + } + } +} + +impl InMemoryLedger { + fn new_from_icrc1_ledger_blocks( + blocks: &[ic_icrc1::Block], + ) -> InMemoryLedger { + let mut state = InMemoryLedger::default(); + for block in blocks { + if let Some(fee_collector) = block.fee_collector { + state.fee_collector = Some(fee_collector); + } + match &block.transaction.operation { + Operation::Mint { to, amount } => state.process_mint(to, amount), + Operation::Transfer { + from, + to, + spender, + amount, + fee, + } => { + state.process_transfer(from, to, spender, amount, &fee.or(block.effective_fee)) + } + Operation::Burn { + from, + spender, + amount, + } => state.process_burn(from, spender, amount), + Operation::Approve { + from, + spender, + amount, + expected_allowance, + expires_at, + fee, + } => state.process_approve( + from, + spender, + amount, + expected_allowance, + expires_at, + &fee.or(block.effective_fee), + TimeStamp::from_nanos_since_unix_epoch(block.timestamp), + ), + } + state.validate_invariants(); + } + state.prune_expired_allowances(TimeStamp::from_nanos_since_unix_epoch( + blocks.last().unwrap().timestamp, + )); + state + } +} + +pub fn verify_ledger_state(env: &StateMachine, ledger_id: CanisterId) { + println!("verifying state of ledger {}", ledger_id); + let blocks = get_all_ledger_and_archive_blocks(env, ledger_id); + println!("retrieved all ledger and archive blocks"); + let expected_ledger_state = InMemoryLedger::new_from_icrc1_ledger_blocks(&blocks); + println!("recreated expected ledger state"); + let actual_num_approvals = parse_metric(env, ledger_id, "ledger_num_approvals"); + let actual_num_balances = parse_metric(env, ledger_id, "ledger_balance_store_entries"); + assert_eq!( + expected_ledger_state.balances.len() as u64, + actual_num_balances, + "Mismatch in number of balances ({} vs {})", + expected_ledger_state.balances.len(), + actual_num_balances + ); + assert_eq!( + expected_ledger_state.allowances.len() as u64, + actual_num_approvals, + "Mismatch in number of approvals ({} vs {})", + expected_ledger_state.allowances.len(), + actual_num_approvals + ); + println!( + "Checking {} balances and {} allowances", + actual_num_balances, actual_num_approvals + ); + for (account, balance) in expected_ledger_state.balances.iter() { + let actual_balance = Decode!( + &env.query(ledger_id, "icrc1_balance_of", Encode!(account).unwrap()) + .expect("failed to query balance") + .bytes(), + Nat + ) + .expect("failed to decode balance_of response"); + + assert_eq!( + &Tokens::try_from(actual_balance.clone()).unwrap(), + balance, + "Mismatch in balance for account {:?} ({} vs {})", + account, + balance, + actual_balance + ); + } + for (approval, allowance) in expected_ledger_state.allowances.iter() { + let (from, spender): (Account, Account) = approval.clone().into(); + assert!( + !allowance.amount.is_zero(), + "Expected allowance is zero! Should not happen... from: {:?}, spender: {:?}", + &from, + &spender + ); + let actual_allowance = get_allowance(env, ledger_id, from, spender); + assert_eq!( + allowance.amount, + Tokens::try_from(actual_allowance.allowance.clone()).unwrap(), + "Mismatch in allowance for approval from {:?} spender {:?}: {:?} ({:?} vs {:?})", + &from, + &spender, + approval, + allowance, + actual_allowance + ); + } + println!("ledger state verified successfully"); +} diff --git a/rs/rosetta-api/icrc1/ledger/sm-tests/src/in_memory_ledger/tests.rs b/rs/rosetta-api/icrc1/ledger/sm-tests/src/in_memory_ledger/tests.rs new file mode 100644 index 00000000000..5456f92853d --- /dev/null +++ b/rs/rosetta-api/icrc1/ledger/sm-tests/src/in_memory_ledger/tests.rs @@ -0,0 +1,419 @@ +use crate::in_memory_ledger::{ApprovalKey, InMemoryLedger, InMemoryLedgerState, Tokens}; +use ic_ledger_core::approvals::Allowance; +use ic_ledger_core::timestamp::TimeStamp; +use ic_ledger_core::tokens::{CheckedAdd, CheckedSub}; +use ic_types::PrincipalId; +use icrc_ledger_types::icrc1::account::Account; + +const ACCOUNT_ID_1: u64 = 134; +const ACCOUNT_ID_2: u64 = 256; +const ACCOUNT_ID_3: u64 = 378; +const MINT_AMOUNT: u64 = 1_000_000u64; +const BURN_AMOUNT: u64 = 500_000u64; +const TRANSFER_AMOUNT: u64 = 200_000u64; +const APPROVE_AMOUNT: u64 = 250_000u64; +const ANOTHER_APPROVE_AMOUNT: u64 = 700_000u64; +const ZERO_AMOUNT: u64 = 0u64; +const FEE_AMOUNT: u64 = 10_000u64; +const TIMESTAMP_NOW: u64 = 0; +const TIMESTAMP_LATER: u64 = 1; + +struct LedgerBuilder { + ledger: InMemoryLedger, +} + +impl LedgerBuilder { + fn new() -> Self { + Self { + ledger: InMemoryLedger::default(), + } + } + + fn with_mint(mut self, to: &Account, amount: &Tokens) -> Self { + self.ledger.process_mint(to, amount); + self.ledger.validate_invariants(); + self + } + + fn with_burn(mut self, from: &Account, spender: &Option, amount: &Tokens) -> Self { + self.ledger.process_burn(from, spender, amount); + self.ledger.validate_invariants(); + self + } + + fn with_transfer( + mut self, + from: &Account, + to: &Account, + spender: &Option, + amount: &Tokens, + fee: &Option, + ) -> Self { + self.ledger.process_transfer(from, to, spender, amount, fee); + self.ledger.validate_invariants(); + self + } + + fn with_approve( + mut self, + from: &Account, + spender: &Account, + amount: &Tokens, + expected_allowance: &Option, + expires_at: &Option, + fee: &Option, + now: TimeStamp, + ) -> Self { + self.ledger.process_approve( + from, + spender, + amount, + expected_allowance, + expires_at, + fee, + now, + ); + self.ledger.validate_invariants(); + self + } + + fn build(self) -> InMemoryLedger { + self.ledger + } +} + +#[test] +fn should_increase_balance_and_total_supply_with_mint() { + let ledger = LedgerBuilder::new() + .with_mint(&account_from_u64(ACCOUNT_ID_1), &Tokens::from(MINT_AMOUNT)) + .build(); + + assert_eq!(ledger.balances.len(), 1); + assert!(ledger.allowances.is_empty()); + assert_eq!(ledger.total_supply, Tokens::from(MINT_AMOUNT)); + let actual_balance = ledger.balances.get(&account_from_u64(ACCOUNT_ID_1)); + assert_eq!(Some(&Tokens::from(MINT_AMOUNT)), actual_balance); + assert_eq!(ledger.total_supply, Tokens::from(MINT_AMOUNT)); +} + +#[test] +fn should_decrease_balance_with_burn() { + let ledger = LedgerBuilder::new() + .with_mint(&account_from_u64(ACCOUNT_ID_1), &Tokens::from(MINT_AMOUNT)) + .with_burn( + &account_from_u64(ACCOUNT_ID_1), + &None, + &Tokens::from(BURN_AMOUNT), + ) + .build(); + + let expected_balance = Tokens::from(MINT_AMOUNT) + .checked_sub(&Tokens::from(BURN_AMOUNT)) + .unwrap(); + + assert_eq!(ledger.total_supply, expected_balance); + assert_eq!(ledger.balances.len(), 1); + assert!(ledger.allowances.is_empty()); + let actual_balance = ledger.balances.get(&account_from_u64(ACCOUNT_ID_1)); + assert_eq!(Some(&expected_balance), actual_balance); +} + +#[test] +fn should_remove_balance_with_burn() { + let ledger = LedgerBuilder::new() + .with_mint(&account_from_u64(ACCOUNT_ID_1), &Tokens::from(MINT_AMOUNT)) + .with_burn( + &account_from_u64(ACCOUNT_ID_1), + &None, + &Tokens::from(MINT_AMOUNT), + ) + .build(); + + assert_eq!(&ledger.total_supply, &Tokens::from(ZERO_AMOUNT)); + assert!(ledger.balances.is_empty()); + assert!(ledger.allowances.is_empty()); + let actual_balance = ledger.balances.get(&account_from_u64(ACCOUNT_ID_1)); + assert_eq!(None, actual_balance); +} + +#[test] +fn should_increase_and_decrease_balance_with_transfer() { + let ledger = LedgerBuilder::new() + .with_mint(&account_from_u64(ACCOUNT_ID_1), &Tokens::from(MINT_AMOUNT)) + .with_transfer( + &account_from_u64(ACCOUNT_ID_1), + &account_from_u64(ACCOUNT_ID_2), + &None, + &Tokens::from(TRANSFER_AMOUNT), + &Some(Tokens::from(FEE_AMOUNT)), + ) + .build(); + + let expected_balance1 = Tokens::from(MINT_AMOUNT) + .checked_sub(&Tokens::from(TRANSFER_AMOUNT)) + .unwrap() + .checked_sub(&Tokens::from(FEE_AMOUNT)) + .unwrap(); + + assert_eq!( + ledger.total_supply, + Tokens::from(MINT_AMOUNT) + .checked_sub(&Tokens::from(FEE_AMOUNT)) + .unwrap() + ); + assert_eq!(ledger.balances.len(), 2); + assert!(ledger.allowances.is_empty()); + let actual_balance1 = ledger.balances.get(&account_from_u64(ACCOUNT_ID_1)); + assert_eq!(Some(&expected_balance1), actual_balance1); + let actual_balance2 = ledger.balances.get(&account_from_u64(ACCOUNT_ID_2)); + assert_eq!(Some(&Tokens::from(TRANSFER_AMOUNT)), actual_balance2); +} + +#[test] +fn should_remove_balances_with_transfer() { + let ledger = LedgerBuilder::new() + .with_mint(&account_from_u64(ACCOUNT_ID_1), &Tokens::from(FEE_AMOUNT)) + .with_transfer( + &account_from_u64(ACCOUNT_ID_1), + &account_from_u64(ACCOUNT_ID_2), + &None, + &Tokens::from(ZERO_AMOUNT), + &Some(Tokens::from(FEE_AMOUNT)), + ) + .build(); + + assert_eq!(ledger.total_supply, Tokens::from(ZERO_AMOUNT)); + assert!(ledger.balances.is_empty()); +} + +#[test] +fn should_increase_allowance_with_approve() { + let now = TimeStamp::from_nanos_since_unix_epoch(TIMESTAMP_NOW); + let ledger = LedgerBuilder::new() + .with_mint(&account_from_u64(ACCOUNT_ID_1), &Tokens::from(MINT_AMOUNT)) + .with_approve( + &account_from_u64(ACCOUNT_ID_1), + &account_from_u64(ACCOUNT_ID_2), + &Tokens::from(APPROVE_AMOUNT), + &None, + &None, + &Some(Tokens::from(FEE_AMOUNT)), + now, + ) + .build(); + + let expected_balance1 = Tokens::from(MINT_AMOUNT) + .checked_sub(&Tokens::from(FEE_AMOUNT)) + .unwrap(); + assert_eq!(ledger.total_supply, expected_balance1); + assert_eq!(ledger.balances.len(), 1); + assert_eq!(ledger.allowances.len(), 1); + let actual_balance1 = ledger.balances.get(&account_from_u64(ACCOUNT_ID_1)); + assert_eq!(Some(&expected_balance1), actual_balance1); + let allowance_key = ApprovalKey::from(( + &account_from_u64(ACCOUNT_ID_1), + &account_from_u64(ACCOUNT_ID_2), + )); + let account2_allowance = ledger.allowances.get(&allowance_key); + let expected_allowance2: Allowance = Allowance { + amount: Tokens::from(APPROVE_AMOUNT), + expires_at: None, + arrived_at: now, + }; + assert_eq!(account2_allowance, Some(&expected_allowance2)); +} + +#[test] +fn should_reset_allowance_with_second_approve() { + let now = TimeStamp::from_nanos_since_unix_epoch(TIMESTAMP_NOW); + let later = TimeStamp::from_nanos_since_unix_epoch(TIMESTAMP_LATER); + let ledger = LedgerBuilder::new() + .with_mint(&account_from_u64(ACCOUNT_ID_1), &Tokens::from(MINT_AMOUNT)) + .with_approve( + &account_from_u64(ACCOUNT_ID_1), + &account_from_u64(ACCOUNT_ID_2), + &Tokens::from(APPROVE_AMOUNT), + &None, + &None, + &Some(Tokens::from(FEE_AMOUNT)), + now, + ) + .with_approve( + &account_from_u64(ACCOUNT_ID_1), + &account_from_u64(ACCOUNT_ID_2), + &Tokens::from(ANOTHER_APPROVE_AMOUNT), + &None, + &None, + &Some(Tokens::from(FEE_AMOUNT)), + later, + ) + .build(); + + let expected_balance1 = Tokens::from(MINT_AMOUNT) + .checked_sub(&Tokens::from(FEE_AMOUNT)) + .unwrap() + .checked_sub(&Tokens::from(FEE_AMOUNT)) + .unwrap(); + assert_eq!(ledger.total_supply, expected_balance1); + assert_eq!(ledger.balances.len(), 1); + assert_eq!(ledger.allowances.len(), 1); + let actual_balance1 = ledger.balances.get(&account_from_u64(ACCOUNT_ID_1)); + assert_eq!(Some(&expected_balance1), actual_balance1); + let allowance_key = ApprovalKey::from(( + &account_from_u64(ACCOUNT_ID_1), + &account_from_u64(ACCOUNT_ID_2), + )); + let account2_allowance = ledger.allowances.get(&allowance_key); + let expected_allowance2: Allowance = Allowance { + amount: Tokens::from(ANOTHER_APPROVE_AMOUNT), + expires_at: None, + arrived_at: later, + }; + assert_eq!(account2_allowance, Some(&expected_allowance2)); +} + +#[test] +fn should_remove_allowance_when_set_to_zero() { + let now = TimeStamp::from_nanos_since_unix_epoch(TIMESTAMP_NOW); + let later = TimeStamp::from_nanos_since_unix_epoch(TIMESTAMP_LATER); + let ledger = LedgerBuilder::new() + .with_mint(&account_from_u64(ACCOUNT_ID_1), &Tokens::from(MINT_AMOUNT)) + .with_approve( + &account_from_u64(ACCOUNT_ID_1), + &account_from_u64(ACCOUNT_ID_2), + &Tokens::from(APPROVE_AMOUNT), + &None, + &None, + &Some(Tokens::from(FEE_AMOUNT)), + now, + ) + .with_approve( + &account_from_u64(ACCOUNT_ID_1), + &account_from_u64(ACCOUNT_ID_2), + &Tokens::from(ZERO_AMOUNT), + &None, + &None, + &Some(Tokens::from(FEE_AMOUNT)), + later, + ) + .build(); + + let expected_balance1 = Tokens::from(MINT_AMOUNT) + .checked_sub(&Tokens::from(FEE_AMOUNT)) + .unwrap() + .checked_sub(&Tokens::from(FEE_AMOUNT)) + .unwrap(); + assert_eq!(ledger.total_supply, expected_balance1); + assert_eq!(ledger.balances.len(), 1); + assert!(ledger.allowances.is_empty()); + let actual_balance1 = ledger.balances.get(&account_from_u64(ACCOUNT_ID_1)); + assert_eq!(Some(&expected_balance1), actual_balance1); + let allowance_key = ApprovalKey::from(( + &account_from_u64(ACCOUNT_ID_1), + &account_from_u64(ACCOUNT_ID_2), + )); + let account2_allowance = ledger.allowances.get(&allowance_key); + assert_eq!(account2_allowance, None); +} + +#[test] +fn should_remove_allowance_when_used_up() { + let now = TimeStamp::from_nanos_since_unix_epoch(TIMESTAMP_NOW); + let ledger = LedgerBuilder::new() + .with_mint(&account_from_u64(ACCOUNT_ID_1), &Tokens::from(MINT_AMOUNT)) + .with_approve( + &account_from_u64(ACCOUNT_ID_1), + &account_from_u64(ACCOUNT_ID_2), + &Tokens::from(APPROVE_AMOUNT) + .checked_add(&Tokens::from(FEE_AMOUNT)) + .unwrap(), + &None, + &None, + &Some(Tokens::from(FEE_AMOUNT)), + now, + ) + .with_transfer( + &account_from_u64(ACCOUNT_ID_1), + &account_from_u64(ACCOUNT_ID_3), + &Some(account_from_u64(ACCOUNT_ID_2)), + &Tokens::from(APPROVE_AMOUNT), + &Some(Tokens::from(FEE_AMOUNT)), + ) + .build(); + + let expected_total_supply = Tokens::from(MINT_AMOUNT) + .checked_sub(&Tokens::from(FEE_AMOUNT)) + .unwrap() + .checked_sub(&Tokens::from(FEE_AMOUNT)) + .unwrap(); + let expected_balance1 = expected_total_supply + .checked_sub(&Tokens::from(APPROVE_AMOUNT)) + .unwrap(); + assert_eq!(ledger.total_supply, expected_total_supply); + assert_eq!(ledger.balances.len(), 2); + assert!(ledger.allowances.is_empty()); + let actual_balance1 = ledger.balances.get(&account_from_u64(ACCOUNT_ID_1)); + assert_eq!(Some(&expected_balance1), actual_balance1); + let actual_balance3 = ledger.balances.get(&account_from_u64(ACCOUNT_ID_3)); + assert_eq!(Some(&Tokens::from(APPROVE_AMOUNT)), actual_balance3); + let allowance_key = ApprovalKey::from(( + &account_from_u64(ACCOUNT_ID_1), + &account_from_u64(ACCOUNT_ID_2), + )); + let account2_allowance = ledger.allowances.get(&allowance_key); + assert_eq!(account2_allowance, None); +} + +#[test] +fn should_increase_and_decrease_balance_with_transfer_from() { + let now = TimeStamp::from_nanos_since_unix_epoch(TIMESTAMP_NOW); + let ledger = LedgerBuilder::new() + .with_mint(&account_from_u64(ACCOUNT_ID_1), &Tokens::from(MINT_AMOUNT)) + .with_approve( + &account_from_u64(ACCOUNT_ID_1), + &account_from_u64(ACCOUNT_ID_2), + &Tokens::from(APPROVE_AMOUNT), + &None, + &None, + &Some(Tokens::from(FEE_AMOUNT)), + now, + ) + .with_transfer( + &account_from_u64(ACCOUNT_ID_1), + &account_from_u64(ACCOUNT_ID_3), + &Some(account_from_u64(ACCOUNT_ID_2)), + &Tokens::from(TRANSFER_AMOUNT), + &Some(Tokens::from(FEE_AMOUNT)), + ) + .build(); + + let expected_balance1 = Tokens::from(MINT_AMOUNT) + .checked_sub(&Tokens::from(TRANSFER_AMOUNT)) + .unwrap() + .checked_sub(&Tokens::from(FEE_AMOUNT)) + .unwrap() + .checked_sub(&Tokens::from(FEE_AMOUNT)) + .unwrap(); + + assert_eq!( + ledger.total_supply, + Tokens::from(MINT_AMOUNT) + .checked_sub(&Tokens::from(FEE_AMOUNT)) + .unwrap() + .checked_sub(&Tokens::from(FEE_AMOUNT)) + .unwrap() + ); + assert_eq!(ledger.balances.len(), 2); + assert_eq!(ledger.allowances.len(), 1); + let actual_balance1 = ledger.balances.get(&account_from_u64(ACCOUNT_ID_1)); + assert_eq!(Some(&expected_balance1), actual_balance1); + let actual_balance3 = ledger.balances.get(&account_from_u64(ACCOUNT_ID_3)); + assert_eq!(Some(&Tokens::from(TRANSFER_AMOUNT)), actual_balance3); +} + +fn account_from_u64(account_id: u64) -> Account { + Account { + owner: PrincipalId::new_user_test_id(account_id).0, + subaccount: None, + } +} diff --git a/rs/rosetta-api/icrc1/ledger/sm-tests/src/lib.rs b/rs/rosetta-api/icrc1/ledger/sm-tests/src/lib.rs index 367f672fe1b..41de7877b0a 100644 --- a/rs/rosetta-api/icrc1/ledger/sm-tests/src/lib.rs +++ b/rs/rosetta-api/icrc1/ledger/sm-tests/src/lib.rs @@ -28,9 +28,9 @@ use icrc_ledger_types::icrc21::requests::{ use icrc_ledger_types::icrc21::responses::{ConsentInfo, ConsentMessage}; use icrc_ledger_types::icrc3; use icrc_ledger_types::icrc3::archive::ArchiveInfo; -use icrc_ledger_types::icrc3::blocks::BlockRange; -use icrc_ledger_types::icrc3::blocks::GenericBlock as IcrcBlock; -use icrc_ledger_types::icrc3::blocks::GetBlocksResponse; +use icrc_ledger_types::icrc3::blocks::{ + BlockRange, GenericBlock as IcrcBlock, GetBlocksRequest, GetBlocksResponse, +}; use icrc_ledger_types::icrc3::transactions::GetTransactionsRequest; use icrc_ledger_types::icrc3::transactions::GetTransactionsResponse; use icrc_ledger_types::icrc3::transactions::Transaction as Tx; @@ -45,6 +45,7 @@ use std::{ time::{Duration, SystemTime}, }; +pub mod in_memory_ledger; pub mod metrics; pub const FEE: u64 = 10_000; @@ -279,6 +280,66 @@ fn icrc21_consent_message( .expect("failed to decode icrc21_canister_call_consent_message response") } +pub fn get_all_ledger_and_archive_blocks( + state_machine: &StateMachine, + ledger_id: CanisterId, +) -> Vec> { + let req = GetBlocksRequest { + start: icrc_ledger_types::icrc1::transfer::BlockIndex::from(0u64), + length: Nat::from(u32::MAX), + }; + let req = Encode!(&req).expect("Failed to encode GetBlocksRequest"); + let res = state_machine + .query(ledger_id, "get_blocks", req) + .expect("Failed to send get_blocks request") + .bytes(); + let res = Decode!(&res, GetBlocksResponse).expect("Failed to decode GetBlocksResponse"); + // Assume that all blocks in the ledger can be retrieved in a single call. This should hold for + // most tests. + let blocks_in_ledger = res + .chain_length + .saturating_sub(res.first_index.0.to_u64().unwrap()); + assert!( + blocks_in_ledger <= res.blocks.len() as u64, + "Chain length: {}, first block index: {}, retrieved blocks: {}", + res.chain_length, + res.first_index, + res.blocks.len() + ); + let mut blocks = vec![]; + for archived in res.archived_blocks { + let mut remaining = archived.length.clone(); + let mut next_archived_txid = archived.start.clone(); + while remaining > 0u32 { + let req = GetTransactionsRequest { + start: next_archived_txid.clone(), + length: remaining.clone(), + }; + let req = + Encode!(&req).expect("Failed to encode GetTransactionsRequest for archive node"); + let canister_id = archived.callback.canister_id; + let res = state_machine + .query( + CanisterId::unchecked_from_principal(PrincipalId(canister_id)), + archived.callback.method.clone(), + req, + ) + .expect("Failed to send get_blocks request to archive") + .bytes(); + let res = Decode!(&res, BlockRange).unwrap(); + next_archived_txid += res.blocks.len() as u64; + remaining -= res.blocks.len() as u32; + blocks.extend(res.blocks); + } + } + blocks.extend(res.blocks); + blocks + .into_iter() + .map(ic_icrc1::Block::try_from) + .collect::>, String>>() + .expect("should convert generic blocks to ICRC1 blocks") +} + fn get_archive_remaining_capacity(env: &StateMachine, archive: Principal) -> u64 { let canister_id = CanisterId::unchecked_from_principal(archive.into()); Decode!( diff --git a/rs/rosetta-api/icrc1/ledger/sm-tests/src/metrics.rs b/rs/rosetta-api/icrc1/ledger/sm-tests/src/metrics.rs index 457a7d0132b..8dfb8d08acb 100644 --- a/rs/rosetta-api/icrc1/ledger/sm-tests/src/metrics.rs +++ b/rs/rosetta-api/icrc1/ledger/sm-tests/src/metrics.rs @@ -169,7 +169,7 @@ fn assert_existence_of_metric(env: &StateMachine, canister_id: CanisterId, metri ); } -fn parse_metric(env: &StateMachine, canister_id: CanisterId, metric: &str) -> u64 { +pub(crate) fn parse_metric(env: &StateMachine, canister_id: CanisterId, metric: &str) -> u64 { let metrics = retrieve_metrics(env, canister_id); for line in &metrics { let tokens: Vec<&str> = line.split(' ').collect(); diff --git a/rs/rosetta-api/icrc1/ledger/tests/tests.rs b/rs/rosetta-api/icrc1/ledger/tests/tests.rs index d172700b177..3376e4e37fc 100644 --- a/rs/rosetta-api/icrc1/ledger/tests/tests.rs +++ b/rs/rosetta-api/icrc1/ledger/tests/tests.rs @@ -2,6 +2,7 @@ use candid::{CandidType, Decode, Encode, Nat}; use ic_base_types::{CanisterId, PrincipalId}; use ic_icrc1::{Block, Operation, Transaction}; use ic_icrc1_ledger::{ChangeFeeCollector, FeatureFlags, InitArgs, LedgerArgument}; +use ic_icrc1_ledger_sm_tests::in_memory_ledger::verify_ledger_state; use ic_icrc1_ledger_sm_tests::{ get_allowance, send_approval, send_transfer_from, ARCHIVE_TRIGGER_THRESHOLD, BLOB_META_KEY, BLOB_META_VALUE, DECIMAL_PLACES, FEE, INT_META_KEY, INT_META_VALUE, MINTER, NAT_META_KEY, @@ -1047,6 +1048,8 @@ fn test_icrc3_get_blocks() { // multiple ranges check_icrc3_get_blocks(vec![(2, 3), (1, 2), (0, 10), (10, 5)]); + + verify_ledger_state(&env, ledger_id); } #[test]