diff --git a/Cargo.lock b/Cargo.lock index c10e1e0611b4..568d41c2ae60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10653,6 +10653,7 @@ dependencies = [ "ic-base-types", "ic-http-types", "ic-icrc1", + "ic-icrc3-test-ledger", "ic-ledger-core", "ic-management-canister-types-private", "ic-state-machine-tests", diff --git a/packages/icrc-ledger-types/CHANGELOG.md b/packages/icrc-ledger-types/CHANGELOG.md index c35cf02fc772..e29b3c450f2c 100644 --- a/packages/icrc-ledger-types/CHANGELOG.md +++ b/packages/icrc-ledger-types/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `try_from_subaccount_to_principal` that returns an error rather than panicking if the subaccount is not a valid Principal. +- add optional fee to `Mint` and `Burn` icrc3 operations. ## 0.1.11 diff --git a/packages/icrc-ledger-types/src/icrc3/schema.rs b/packages/icrc-ledger-types/src/icrc3/schema.rs index 4d22db2fe23c..3268b3f862c0 100644 --- a/packages/icrc-ledger-types/src/icrc3/schema.rs +++ b/packages/icrc-ledger-types/src/icrc3/schema.rs @@ -43,11 +43,13 @@ pub fn validate(block: &Value) -> Result<(), ValuePredicateFailures> { icrc1_common.clone(), item("op", Required, is(Value::text("burn"))), item("from", Required, is_account.clone()), + item("fee", Optional, is_amount.clone()), ]); let is_icrc1_mint = and(vec![ icrc1_common.clone(), item("op", Required, is(Value::text("mint"))), item("to", Required, is_account.clone()), + item("fee", Optional, is_amount.clone()), ]); let is_icrc2_approve = and(vec![ icrc1_common.clone(), diff --git a/packages/icrc-ledger-types/src/icrc3/transactions.rs b/packages/icrc-ledger-types/src/icrc3/transactions.rs index e6f9f06141bb..f71945bce7aa 100644 --- a/packages/icrc-ledger-types/src/icrc3/transactions.rs +++ b/packages/icrc-ledger-types/src/icrc3/transactions.rs @@ -22,6 +22,7 @@ pub struct Mint { pub to: Account, pub memo: Option, pub created_at_time: Option, + pub fee: Option, } #[derive(CandidType, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] @@ -31,6 +32,7 @@ pub struct Burn { pub spender: Option, pub memo: Option, pub created_at_time: Option, + pub fee: Option, } #[derive(CandidType, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] diff --git a/rs/ethereum/cketh/minter/tests/ckerc20.rs b/rs/ethereum/cketh/minter/tests/ckerc20.rs index 39f3a0e761de..2f1a1f217a85 100644 --- a/rs/ethereum/cketh/minter/tests/ckerc20.rs +++ b/rs/ethereum/cketh/minter/tests/ckerc20.rs @@ -440,6 +440,7 @@ mod withdraw_erc20 { .unwrap(), })), created_at_time: None, + fee: None, }); let balance_after_withdrawal = ckerc20.cketh.balance_of(caller); @@ -485,6 +486,7 @@ mod withdraw_erc20 { withdrawal_id: cketh_burn_index.into(), })), created_at_time: None, + fee: None, }); } @@ -725,6 +727,7 @@ mod withdraw_erc20 { .unwrap(), })), created_at_time: None, + fee: None, }) .call_ckerc20_ledger_get_transaction( deposit_params.token().ledger_canister_id, @@ -744,6 +747,7 @@ mod withdraw_erc20 { .unwrap(), })), created_at_time: None, + fee: None, }); let expected_cketh_balance_after_withdrawal = @@ -873,6 +877,7 @@ mod withdraw_erc20 { tx_hash: DEFAULT_CKERC20_WITHDRAWAL_TRANSACTION_HASH.parse().unwrap(), })), created_at_time: None, + fee: None, }); } } @@ -1361,6 +1366,7 @@ fn should_deposit_ckerc20() { log_index: params.transaction_data().log_index.into(), })), created_at_time: None, + fee: None, }); } } @@ -1454,6 +1460,7 @@ fn should_deposit_cketh_and_ckerc20() { log_index: cketh_params.transaction_data().log_index.into(), })), created_at_time: None, + fee: None, }) .call_ckerc20_ledger_get_transaction(params.token().ledger_canister_id, 0_u8) .expect_mint(Mint { @@ -1465,6 +1472,7 @@ fn should_deposit_cketh_and_ckerc20() { log_index: params.transaction_data().log_index.into(), })), created_at_time: None, + fee: None, }); } } diff --git a/rs/ethereum/cketh/minter/tests/cketh.rs b/rs/ethereum/cketh/minter/tests/cketh.rs index ea0e0e0f3a6b..540ac0b673aa 100644 --- a/rs/ethereum/cketh/minter/tests/cketh.rs +++ b/rs/ethereum/cketh/minter/tests/cketh.rs @@ -93,6 +93,7 @@ fn should_deposit_and_withdraw() { log_index: DEFAULT_DEPOSIT_LOG_INDEX.into(), })), created_at_time: None, + fee: None, }) .call_ledger_approve_minter(account.owner, EXPECTED_BALANCE, account.subaccount) .expect_ok(1) @@ -124,6 +125,7 @@ fn should_deposit_and_withdraw() { to_address: destination.parse().unwrap(), })), created_at_time: None, + fee: None, }); assert_eq!(cketh.balance_of(account), Nat::from(0_u8)); @@ -454,6 +456,7 @@ fn should_reimburse() { log_index: DEFAULT_DEPOSIT_LOG_INDEX.into(), })), created_at_time: None, + fee: None, }) .call_ledger_approve_minter(caller, EXPECTED_BALANCE, None) .expect_ok(1); @@ -493,6 +496,7 @@ fn should_reimburse() { to_address: destination.parse().unwrap(), })), created_at_time: None, + fee: None, }); assert_eq!(cketh.balance_of(caller), Nat::from(0_u8)); @@ -543,6 +547,7 @@ fn should_reimburse() { tx_hash: failed_tx_hash.parse().unwrap(), })), created_at_time: None, + fee: None, }) .assert_has_unique_events_in_order(&vec![ EventPayload::AcceptedEthWithdrawalRequest { diff --git a/rs/ledger_suite/common/ledger_canister_core/src/ledger.rs b/rs/ledger_suite/common/ledger_canister_core/src/ledger.rs index a19eda8c9d9d..d6c1403b2c85 100644 --- a/rs/ledger_suite/common/ledger_canister_core/src/ledger.rs +++ b/rs/ledger_suite/common/ledger_canister_core/src/ledger.rs @@ -18,7 +18,7 @@ use crate::archive::{ArchivingGuardError, FailedToArchiveBlocks, LedgerArchiving use ic_ledger_core::balances::{BalanceError, Balances, BalancesStore}; use ic_ledger_core::block::{BlockIndex, BlockType, EncodedBlock, FeeCollector}; use ic_ledger_core::timestamp::TimeStamp; -use ic_ledger_core::tokens::TokensType; +use ic_ledger_core::tokens::{TokensType, Zero}; use ic_ledger_hash_of::HashOf; #[derive(Debug, Deserialize, Serialize)] @@ -34,6 +34,7 @@ pub enum TxApplyError { ExpiredApproval { now: TimeStamp }, AllowanceChanged { current_allowance: Tokens }, SelfApproval, + BurnOrMintFee, } impl From> for TxApplyError { @@ -264,6 +265,9 @@ where TransferError::AllowanceChanged { current_allowance } } TxApplyError::SelfApproval => TransferError::SelfApproval, + TxApplyError::BurnOrMintFee => TransferError::BadFee { + expected_fee: L::Tokens::zero(), + }, })?; let fee_collector = ledger.fee_collector().cloned(); diff --git a/rs/ledger_suite/icp/ledger/tests/tests.rs b/rs/ledger_suite/icp/ledger/tests/tests.rs index b85a59769855..32e96b01103a 100644 --- a/rs/ledger_suite/icp/ledger/tests/tests.rs +++ b/rs/ledger_suite/icp/ledger/tests/tests.rs @@ -462,6 +462,14 @@ fn test_mint_burn() { ic_ledger_suite_state_machine_tests::test_mint_burn(ledger_wasm(), encode_init_args); } +#[test] +fn test_mint_burn_fee_rejected() { + ic_ledger_suite_state_machine_tests::test_mint_burn_fee_rejected( + ledger_wasm(), + encode_init_args, + ); +} + #[test] fn test_anonymous_transfers() { ic_ledger_suite_state_machine_tests::test_anonymous_transfers(ledger_wasm(), encode_init_args); diff --git a/rs/ledger_suite/icrc1/archive/archive.did b/rs/ledger_suite/icrc1/archive/archive.did index 3aad2cb11fcd..4eb4323600db 100644 --- a/rs/ledger_suite/icrc1/archive/archive.did +++ b/rs/ledger_suite/icrc1/archive/archive.did @@ -26,14 +26,16 @@ type Burn = record { memo : opt vec nat8; created_at_time : opt nat64; amount : nat; - spender : opt Account + spender : opt Account; + fee : opt nat }; type Mint = record { to : Account; memo : opt vec nat8; created_at_time : opt nat64; - amount : nat + amount : nat; + fee : opt nat }; type Transfer = record { diff --git a/rs/ledger_suite/icrc1/archive/tests/tests.rs b/rs/ledger_suite/icrc1/archive/tests/tests.rs index 5c7fb36b95a1..df26c0d640fd 100644 --- a/rs/ledger_suite/icrc1/archive/tests/tests.rs +++ b/rs/ledger_suite/icrc1/archive/tests/tests.rs @@ -128,6 +128,7 @@ fn test_icrc3_get_blocks() { Operation::Mint { to: Account::from(Principal::anonymous()), amount: Tokens::from(1_000_000_000u64), + fee: None, }, ); let blockid0 = block_with_id(0, block0.clone()); @@ -259,6 +260,7 @@ fn test_icrc3_get_blocks_number_of_blocks_limit() { operation: Operation::Mint { to: Account::from(Principal::anonymous()), amount: Tokens::from(amount as u64), + fee: None, }, created_at_time: None, memo: None, diff --git a/rs/ledger_suite/icrc1/index-ng/index-ng.did b/rs/ledger_suite/icrc1/index-ng/index-ng.did index 53ad6d473735..0f2351be1f92 100644 --- a/rs/ledger_suite/icrc1/index-ng/index-ng.did +++ b/rs/ledger_suite/icrc1/index-ng/index-ng.did @@ -76,14 +76,16 @@ type Burn = record { memo : opt vec nat8; created_at_time : opt nat64; amount : Tokens; - spender : opt Account + spender : opt Account; + fee : opt nat }; type Mint = record { to : Account; memo : opt vec nat8; created_at_time : opt nat64; - amount : Tokens + amount : Tokens; + fee : opt nat }; type Transfer = record { diff --git a/rs/ledger_suite/icrc1/index-ng/src/main.rs b/rs/ledger_suite/icrc1/index-ng/src/main.rs index 7a19e8b95a09..6984dbcece0d 100644 --- a/rs/ledger_suite/icrc1/index-ng/src/main.rs +++ b/rs/ledger_suite/icrc1/index-ng/src/main.rs @@ -111,6 +111,9 @@ thread_local! { /// Cache of the canister, i.e. ephemeral data that doesn't need to be /// persistent between upgrades static CACHE: RefCell = RefCell::new(Cache::default()); + + /// The ID of the block sync timer. + static TIMER_ID: RefCell = RefCell::new(TimerId::default()); } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -215,6 +218,11 @@ struct Cache { pub get_blocks_method: Option, } +struct SyncError { + message: String, + retriable: bool, +} + #[test] fn test_account_data_type_storable() { assert_eq!( @@ -444,7 +452,7 @@ where .map_err(|err| format!("failed to candid decode the output: {err}")) } -async fn get_blocks_from_ledger(start: u64) -> Option { +async fn get_blocks_from_ledger(start: u64) -> Result { let (ledger_id, length) = with_state(|state| (state.ledger_id, state.max_blocks_per_response)); let req = GetBlocksRequest { start: Nat::from(start), @@ -460,17 +468,20 @@ async fn get_blocks_from_ledger(start: u64) -> Option { ) .await; match res { - Ok(res) => Some(res), + Ok(res) => Ok(res), Err(err) => { - log!(P0, "[get_blocks_from_ledger] failed to get blocks: {}", err); - None + let message = format!("[get_blocks_from_ledger] failed to get blocks: {}", err); + Err(SyncError { + message, + retriable: true, + }) } } } async fn get_blocks_from_archive( archived: &ArchivedRange, -) -> Option { +) -> Result { let req = GetBlocksRequest { start: archived.start.clone(), length: archived.length.clone(), @@ -484,19 +495,18 @@ async fn get_blocks_from_archive( ) .await; match res { - Ok(res) => Some(res), + Ok(res) => Ok(res), Err(err) => { - log!( - P0, - "[get_blocks_from_archive] failed to get blocks: {}", - err - ); - None + let message = format!("[get_blocks_from_archive] failed to get blocks: {}", err); + Err(SyncError { + message, + retriable: true, + }) } } } -async fn icrc3_get_blocks_from_ledger(start: u64) -> Option { +async fn icrc3_get_blocks_from_ledger(start: u64) -> Result { let (ledger_id, length) = with_state(|state| (state.ledger_id, state.max_blocks_per_response)); let req = vec![GetBlocksRequest { start: Nat::from(start), @@ -512,19 +522,23 @@ async fn icrc3_get_blocks_from_ledger(start: u64) -> Option { ) .await; match res { - Ok(res) => Some(res), + Ok(res) => Ok(res), Err(err) => { - log!( - P0, + let message = format!( "[icrc3_get_blocks_from_ledger] failed to get blocks: {}", err ); - None + Err(SyncError { + message, + retriable: true, + }) } } } -async fn icrc3_get_blocks_from_archive(archived: &ArchivedBlocks) -> Option { +async fn icrc3_get_blocks_from_archive( + archived: &ArchivedBlocks, +) -> Result { let res = measured_call( "build_index.icrc3_get_blocks_from_archive.encode", "build_index.icrc3_get_blocks_from_archive.decode", @@ -534,14 +548,16 @@ async fn icrc3_get_blocks_from_archive(archived: &ArchivedBlocks) -> Option Some(res), + Ok(res) => Ok(res), Err(err) => { - log!( - P0, + let message = format!( "[icrc3_get_blocks_from_archive] failed to get blocks: {}", err ); - None + Err(SyncError { + message, + retriable: true, + }) } } } @@ -573,21 +589,36 @@ pub async fn build_index() -> Option<()> { }); }); let num_indexed = match find_get_blocks_method().await { - GetBlocksMethod::GetBlocks => fetch_blocks_via_get_blocks().await?, - GetBlocksMethod::ICRC3GetBlocks => fetch_blocks_via_icrc3().await?, + GetBlocksMethod::GetBlocks => fetch_blocks_via_get_blocks().await, + GetBlocksMethod::ICRC3GetBlocks => fetch_blocks_via_icrc3().await, }; - let retrieve_blocks_from_ledger_interval = - with_state(|state| state.retrieve_blocks_from_ledger_interval()); - log!( - P1, - "Indexed: {} waiting : {:?}", - num_indexed, - retrieve_blocks_from_ledger_interval - ); + match num_indexed { + Ok(num_indexed) => { + let retrieve_blocks_from_ledger_interval = + with_state(|state| state.retrieve_blocks_from_ledger_interval()); + log!( + P1, + "Indexed: {} waiting : {:?}", + num_indexed, + retrieve_blocks_from_ledger_interval + ); + } + Err(error) => { + log!(P0, "{}", error.message); + ic_cdk::eprintln!("{}", error.message); + if !error.retriable { + log!(P0, "Stopping the indexing timer."); + ic_cdk::eprintln!("Stopping the indexing timer."); + let timer_id = TIMER_ID.with(|tid| *tid.borrow()); + ic_cdk_timers::clear_timer(timer_id); + } + } + }; + Some(()) } -async fn fetch_blocks_via_get_blocks() -> Option { +async fn fetch_blocks_via_get_blocks() -> Result { let mut num_indexed = 0; let next_id = with_blocks(|blocks| blocks.len()); let res = get_blocks_from_ledger(next_id).await?; @@ -604,15 +635,15 @@ async fn fetch_blocks_via_get_blocks() -> Option { next_archived_txid += res.blocks.len(); num_indexed += res.blocks.len(); remaining -= res.blocks.len(); - append_blocks(res.blocks); + append_blocks(res.blocks)?; } } num_indexed += res.blocks.len(); - append_blocks(res.blocks); - Some(num_indexed as u64) + append_blocks(res.blocks)?; + Ok(num_indexed as u64) } -async fn fetch_blocks_via_icrc3() -> Option { +async fn fetch_blocks_via_icrc3() -> Result { // The current number of blocks is also the id of the next // block to query from the Ledger. let previous_num_blocks = with_blocks(|blocks| blocks.len()); @@ -637,13 +668,14 @@ async fn fetch_blocks_via_icrc3() -> Option { // one, i.e. next_id + num_indexed let expected_id = with_blocks(|blocks| blocks.len()); if arg.start != expected_id { - log!( - P0, + let error = format!( "[fetch_blocks_via_icrc3]: wrong start index in archive args. Expected: {} actual: {}", - expected_id, - arg.start, + expected_id, arg.start, ); - return None; + return Err(SyncError { + message: error, + retriable: false, + }); } let archived = ArchivedBlocks { @@ -654,14 +686,16 @@ async fn fetch_blocks_via_icrc3() -> Option { // sanity check: the index does not support nested archives if !res.archived_blocks.is_empty() { - log!( - P0, + let error = format!( "[fetch_blocks_via_icrc3]: The archive callback {:?} with arg {:?} returned one or more archived blocks and the index is currently not supporting nested archived blocks. Archived blocks returned are {:?}", callback.clone(), arg.clone(), res.archived_blocks, ); - return None; + return Err(SyncError { + message: error, + retriable: false, + }); } // change `arg` for the next iteration @@ -679,21 +713,61 @@ async fn fetch_blocks_via_icrc3() -> Option { "The number of blocks {} is smaller than the number of blocks before indexing {}. This is impossible. I'm trapping to reset the state", num_blocks, previous_num_blocks ), - Some(new_blocks_indexed) => Some(new_blocks_indexed), + Some(new_blocks_indexed) => Ok(new_blocks_indexed), } } -fn set_build_index_timer(after: Duration) -> TimerId { - ic_cdk_timers::set_timer_interval(after, || { +fn set_build_index_timer(after: Duration) { + let timer_id = ic_cdk_timers::set_timer_interval(after, || { ic_cdk::spawn(async { let _ = build_index().await; }) - }) + }); + TIMER_ID.with(|tid| *tid.borrow_mut() = timer_id); } -fn append_block(block_index: BlockIndex64, block: GenericBlock) { +fn append_block(block_index: BlockIndex64, block: GenericBlock) -> Result<(), SyncError> { measure_span(&PROFILING_DATA, "append_blocks", move || { - let block = generic_block_to_encoded_block_or_trap(block_index, block); + let original_block = block.clone(); + + let block = match generic_block_to_encoded_block(block) { + Ok(block) => block, + Err(e) => { + let message = format!( + "Unable to decode generic block at index {block_index}: {}. Error: {e}", + original_block + ); + return Err(SyncError { + message, + retriable: false, + }); + } + }; + + let decoded_block = match Block::::decode(block.clone()) { + Ok(block) => block, + Err(e) => { + let message = format!( + "Unable to decode encoded block at index {block_index}: {}. Error: {e}", + original_block + ); + return Err(SyncError { + message, + retriable: false, + }); + } + }; + let decoded_value = encoded_block_to_generic_block(&decoded_block.clone().encode()); + if original_block.hash() != decoded_value.hash() { + let message = format!( + "Block at index {block_index} has unknown fields. Original block: {}, decoded block: {}.", + original_block, decoded_value + ); + return Err(SyncError { + message, + retriable: false, + }); + } // append the encoded block to the block log with_blocks(|blocks| { @@ -702,8 +776,6 @@ fn append_block(block_index: BlockIndex64, block: GenericBlock) { .unwrap_or_else(|_| trap("no space left")) }); - let decoded_block = decode_encoded_block_or_trap(block_index, block); - // add the block idx to the indices with_account_block_ids(|account_block_ids| { for account in get_accounts(&decoded_block) { @@ -716,40 +788,44 @@ fn append_block(block_index: BlockIndex64, block: GenericBlock) { // change the balance of the involved accounts process_balance_changes(block_index, &decoded_block); - }); + + Ok(()) + }) } -fn append_blocks(new_blocks: Vec) { +fn append_blocks(new_blocks: Vec) -> Result<(), SyncError> { // the index of the next block that we // are going to append let mut block_index = with_blocks(|blocks| blocks.len()); for block in new_blocks { - append_block(block_index, block); + append_block(block_index, block)?; block_index += 1; } + Ok(()) } -fn append_icrc3_blocks(new_blocks: Vec) -> Option<()> { +fn append_icrc3_blocks(new_blocks: Vec) -> Result<(), SyncError> { let mut blocks = vec![]; let start_id = with_blocks(|blocks| blocks.len()); for BlockWithId { id, block } in new_blocks { // sanity check let expected_id = start_id + blocks.len() as u64; if id != expected_id { - log!( - P0, + let error = format!( "[fetch_blocks_via_icrc3]: wrong block index returned by ledger. Expected: {} actual: {}", - expected_id, - id, + expected_id, id ); - return None; + return Err(SyncError { + message: error, + retriable: false, + }); } // This conversion is safe as `Value` // can represent any `ICRC3Value`. blocks.push(Value::from(block)); } - append_blocks(blocks); - Some(()) + append_blocks(blocks)?; + Ok(()) } fn index_fee_collector(block_index: BlockIndex64, block: &Block) { @@ -787,9 +863,12 @@ fn process_balance_changes(block_index: BlockIndex64, block: &Block) { &PROFILING_DATA, "append_blocks.process_balance_changes", move || match block.transaction.operation { - Operation::Burn { from, amount, .. } => { + Operation::Burn { + from, amount, fee, .. + } => { let mut amount_with_fee = amount; - if let Some(fee) = block.effective_fee { + let effective_fee = block.effective_fee.or(fee); + if let Some(fee) = effective_fee { amount_with_fee = amount.checked_add(&fee).unwrap_or_else(|| { trap(format!( "token amount overflow while indexing block {block_index}" @@ -802,9 +881,10 @@ fn process_balance_changes(block_index: BlockIndex64, block: &Block) { } debit(block_index, from, amount_with_fee); } - Operation::Mint { to, amount } => { + Operation::Mint { to, amount, fee } => { let mut amount_without_fee = amount; - if let Some(fee) = block.effective_fee { + let effective_fee = block.effective_fee.or(fee); + if let Some(fee) = effective_fee { amount_without_fee = amount.checked_sub(&fee).unwrap_or_else(|| { trap(format!( "token amount underflow while indexing block {block_index}" @@ -895,17 +975,6 @@ fn credit(block_index: BlockIndex64, account: Account, amount: Tokens) { }); } -fn generic_block_to_encoded_block_or_trap( - block_index: BlockIndex64, - block: GenericBlock, -) -> EncodedBlock { - generic_block_to_encoded_block(block).unwrap_or_else(|e| { - trap(format!( - "Unable to decode generic block at index {block_index}. Error: {e}" - )) - }) -} - fn decode_encoded_block_or_trap(block_index: BlockIndex64, block: EncodedBlock) -> Block { Block::::decode(block).unwrap_or_else(|e| { trap(format!( diff --git a/rs/ledger_suite/icrc1/index-ng/tests/common/mod.rs b/rs/ledger_suite/icrc1/index-ng/tests/common/mod.rs index 901e902bb6c4..97c9843a62f2 100644 --- a/rs/ledger_suite/icrc1/index-ng/tests/common/mod.rs +++ b/rs/ledger_suite/icrc1/index-ng/tests/common/mod.rs @@ -97,6 +97,29 @@ pub fn install_ledger( .unwrap() } +fn icrc3_test_ledger() -> Vec { + let ledger_wasm_path = std::env::var("IC_ICRC3_TEST_LEDGER_WASM_PATH").expect( + "The Ledger wasm path must be set using the env variable IC_ICRC3_TEST_LEDGER_WASM_PATH", + ); + std::fs::read(&ledger_wasm_path).unwrap_or_else(|e| { + panic!( + "failed to load Wasm file from path {} (env var IC_ICRC3_TEST_LEDGER_WASM_PATH): {}", + ledger_wasm_path, e + ) + }) +} + +#[allow(dead_code)] +pub fn install_icrc3_test_ledger(env: &StateMachine) -> CanisterId { + env.install_canister_with_cycles( + icrc3_test_ledger(), + Encode!(&()).unwrap(), + None, + ic_types::Cycles::new(STARTING_CYCLES_PER_CANISTER), + ) + .unwrap() +} + #[allow(dead_code)] pub fn install_index_ng(env: &StateMachine, init_arg: IndexInitArg) -> CanisterId { let args = IndexArg::Init(init_arg); @@ -276,7 +299,7 @@ fn assert_reply(result: WasmResult) -> Vec { } } -fn get_logs(env: &StateMachine, index_id: CanisterId) -> Log { +pub fn get_logs(env: &StateMachine, index_id: CanisterId) -> Log { let request = HttpRequest { method: "".to_string(), url: "/logs".to_string(), diff --git a/rs/ledger_suite/icrc1/index-ng/tests/tests.rs b/rs/ledger_suite/icrc1/index-ng/tests/tests.rs index a452e16cb821..de781b9f64b3 100644 --- a/rs/ledger_suite/icrc1/index-ng/tests/tests.rs +++ b/rs/ledger_suite/icrc1/index-ng/tests/tests.rs @@ -1,7 +1,7 @@ use crate::common::{ ARCHIVE_TRIGGER_THRESHOLD, FEE, MAX_BLOCKS_FROM_ARCHIVE, account, default_archive_options, - index_ng_wasm, install_index_ng, install_ledger, ledger_get_all_blocks, ledger_wasm, - wait_until_sync_is_completed, + get_logs, index_ng_wasm, install_icrc3_test_ledger, install_index_ng, install_ledger, + ledger_get_all_blocks, ledger_wasm, wait_until_sync_is_completed, }; use candid::{Decode, Encode, Nat, Principal}; use ic_agent::identity::Identity; @@ -11,12 +11,17 @@ use ic_icrc1_index_ng::{ GetAccountTransactionsResponse, GetAccountTransactionsResult, GetBlocksResponse, IndexArg, InitArg as IndexInitArg, ListSubaccountsArgs, TransactionWithId, }; -use ic_icrc1_ledger::{ChangeFeeCollector, LedgerArgument, UpgradeArgs as LedgerUpgradeArgs}; +use ic_icrc1_ledger::{ + ChangeFeeCollector, LedgerArgument, Tokens, UpgradeArgs as LedgerUpgradeArgs, +}; use ic_icrc1_test_utils::{ - ArgWithCaller, LedgerEndpointArg, minter_identity, valid_transactions_strategy, + ArgWithCaller, LedgerEndpointArg, icrc3::BlockBuilder, minter_identity, + valid_transactions_strategy, }; +use ic_ledger_suite_state_machine_helpers::{add_block, archive_blocks, set_icrc3_enabled}; use ic_ledger_suite_state_machine_tests::test_http_request_decoding_quota; use ic_state_machine_tests::StateMachine; +use icrc_ledger_types::icrc::generic_value::ICRC3Value; use icrc_ledger_types::icrc1::account::{Account, Subaccount}; use icrc_ledger_types::icrc1::transfer::{BlockIndex, TransferArg, TransferError}; use icrc_ledger_types::icrc2::approve::{ApproveArgs, ApproveError}; @@ -350,6 +355,8 @@ fn assert_ledger_index_parity(env: &StateMachine, ledger_id: CanisterId, index_i ); } } + // Verify there are no errors in the index log. + assert!(get_logs(env, index_id).entries.is_empty()); } #[cfg(any(feature = "get_blocks_disabled", feature = "icrc3_disabled"))] @@ -454,6 +461,107 @@ fn test_ledger_growing() { ); } +// With 6 blocks we can store 2 blocks in 2 archives and the ledger each. +// This way we can test all possible locations for the unknown block: +// - ledger/archive +// - last/not-last archive +// - last block/not last block in the archive/ledger +const NUM_BLOCKS: u64 = 6; + +fn verify_unknown_block_handling( + env: &StateMachine, + ledger_id: CanisterId, + index_id: CanisterId, + bad_block_index: u64, +) { + const TEST_ACCOUNT: Account = Account { + owner: PrincipalId::new_user_test_id(44).0, + subaccount: None, + }; + + for i in 0..NUM_BLOCKS { + let block = BlockBuilder::new(i, i) + .mint(TEST_ACCOUNT, Tokens::from(1u64)) + .build(); + let block = if i == bad_block_index { + let mut bad_block = match block { + ICRC3Value::Map(btree_map) => btree_map, + _ => panic!("block should be a map"), + }; + bad_block.insert("unknown_key".to_string(), ICRC3Value::Nat(Nat::from(0u64))); + ICRC3Value::Map(bad_block) + } else { + block + }; + add_block(env, ledger_id, &block).expect("failed adding block to the ledger"); + } + + let archive1 = install_icrc3_test_ledger(env); + let archive2 = install_icrc3_test_ledger(env); + let archived_count = archive_blocks(env, ledger_id, archive1, 2); + assert_eq!(archived_count, 2); + let archived_count = archive_blocks(env, ledger_id, archive2, 2); + assert_eq!(archived_count, 2); + + // Advance more than once to make sure the indexing was stopped. + for _ in 0..3 { + env.advance_time(Duration::from_secs(60)); + env.tick(); + } + + let ledger_blocks = ledger_get_all_blocks(env, ledger_id, 0, u64::MAX); + let index_blocks = index_get_all_blocks(env, index_id, 0, u64::MAX); + assert_eq!(ledger_blocks.chain_length, NUM_BLOCKS); + assert_eq!(index_blocks.chain_length, bad_block_index); + + assert_eq!( + icrc1_balance_of(env, index_id, TEST_ACCOUNT), + bad_block_index + ); + + let logs = get_logs(env, index_id); + let mut error_count = 0; + let mut stopping_message = false; + for entry in logs.entries { + if entry.message.contains(&format!( + "Block at index {} has unknown fields.", + bad_block_index + )) { + error_count += 1; + } + if entry.message.contains("Stopping the indexing timer.") { + stopping_message = true; + } + } + // This additionally checks whether the indexing was stopped. + assert_eq!(error_count, 1); + assert!(stopping_message); +} + +#[test] +fn test_unknown_block_icrc3() { + for bad_block_index in 0..NUM_BLOCKS { + let env = &StateMachine::new(); + let ledger_id = install_icrc3_test_ledger(env); + let index_id = install_index_ng(env, index_init_arg_without_interval(ledger_id)); + + verify_unknown_block_handling(env, ledger_id, index_id, bad_block_index); + } +} + +#[test] +fn test_unknown_block_legacy() { + for bad_block_index in 0..NUM_BLOCKS { + let env = &StateMachine::new(); + let ledger_id = install_icrc3_test_ledger(env); + let index_id = install_index_ng(env, index_init_arg_without_interval(ledger_id)); + + set_icrc3_enabled(env, ledger_id, false); + + verify_unknown_block_handling(env, ledger_id, index_id, bad_block_index); + } +} + #[test] fn test_archive_indexing() { let env = &StateMachine::new(); @@ -541,6 +649,7 @@ fn test_get_account_transactions() { amount: 1_000_000_000_000_u64.into(), created_at_time: None, memo: None, + fee: None, }, 0, ), @@ -674,6 +783,7 @@ fn test_get_account_transactions_start_length() { amount: (i * 10_000).into(), created_at_time: None, memo: None, + fee: None, }, 0, ), @@ -768,6 +878,7 @@ fn test_get_account_transactions_pagination() { amount: (id * 10_000).into(), created_at_time: None, memo: None, + fee: None, }), transfer: None, approve: None, @@ -1276,12 +1387,9 @@ mod metrics { #[cfg(not(feature = "icrc3_disabled"))] mod fees_in_burn_and_mint_blocks { use super::*; - use crate::common::STARTING_CYCLES_PER_CANISTER; use ic_icrc1_ledger::Tokens; use ic_icrc1_test_utils::icrc3::BlockBuilder; - use ic_icrc3_test_ledger::AddBlockResult; use ic_types::time::GENESIS; - use icrc_ledger_types::icrc::generic_value::ICRC3Value; #[test] fn should_take_mint_block_fee_into_account() { @@ -1301,14 +1409,7 @@ mod fees_in_burn_and_mint_blocks { let env = StateMachine::new(); - let ledger_id = env - .install_canister_with_cycles( - test_ledger_wasm(), - Encode!(&()).unwrap(), - None, - ic_types::Cycles::new(STARTING_CYCLES_PER_CANISTER), - ) - .unwrap(); + let ledger_id = install_icrc3_test_ledger(&env); let mint = BlockBuilder::new(0, GENESIS.as_nanos_since_unix_epoch()) .with_fee(Tokens::from(MINT_FEE)) @@ -1371,14 +1472,7 @@ mod fees_in_burn_and_mint_blocks { let env = StateMachine::new(); - let ledger_id = env - .install_canister_with_cycles( - test_ledger_wasm(), - Encode!(&()).unwrap(), - None, - ic_types::Cycles::new(STARTING_CYCLES_PER_CANISTER), - ) - .unwrap(); + let ledger_id = install_icrc3_test_ledger(&env); let mint = BlockBuilder::new(0, GENESIS.as_nanos_since_unix_epoch()) .with_fee_collector(FEE_COLLECTOR_ACCOUNT) @@ -1434,30 +1528,4 @@ mod fees_in_burn_and_mint_blocks { actual_fee_collector_balance, BURN_FEE ); } - - fn add_block( - env: &StateMachine, - canister_id: CanisterId, - block: &ICRC3Value, - ) -> Result { - Decode!( - &env.execute_ingress(canister_id, "add_block", Encode!(block).unwrap()) - .expect("failed to add block") - .bytes(), - AddBlockResult - ) - .expect("failed to decode add_block response") - } - - fn test_ledger_wasm() -> Vec { - let ledger_wasm_path = std::env::var("IC_ICRC3_TEST_LEDGER_WASM_PATH").expect( - "The Ledger wasm path must be set using the env variable IC_ICRC3_TEST_LEDGER_WASM_PATH", - ); - std::fs::read(&ledger_wasm_path).unwrap_or_else(|e| { - panic!( - "failed to load Wasm file from path {} (env var IC_ICRC3_TEST_LEDGER_WASM_PATH): {}", - ledger_wasm_path, e - ) - }) - } } diff --git a/rs/ledger_suite/icrc1/ledger/block.cddl b/rs/ledger_suite/icrc1/ledger/block.cddl index 6123c8f09c40..2a1df8a1b56d 100644 --- a/rs/ledger_suite/icrc1/ledger/block.cddl +++ b/rs/ledger_suite/icrc1/ledger/block.cddl @@ -26,6 +26,7 @@ BlockContent = { MintTx = ( op: "mint", to: Account, + ? fee: Amount, TxCommon ) @@ -33,6 +34,7 @@ BurnTx = ( op: "burn", from: Account, ? spender: Account, + ? fee: Amount, TxCommon ) diff --git a/rs/ledger_suite/icrc1/ledger/ledger.did b/rs/ledger_suite/icrc1/ledger/ledger.did index b811c633d549..b093ec9bfada 100644 --- a/rs/ledger_suite/icrc1/ledger/ledger.did +++ b/rs/ledger_suite/icrc1/ledger/ledger.did @@ -233,14 +233,16 @@ type Burn = record { memo : opt blob; created_at_time : opt Timestamp; amount : nat; - spender : opt Account + spender : opt Account; + fee : opt nat }; type Mint = record { to : Account; memo : opt blob; created_at_time : opt Timestamp; - amount : nat + amount : nat; + fee : opt nat }; type Transfer = record { diff --git a/rs/ledger_suite/icrc1/ledger/src/main.rs b/rs/ledger_suite/icrc1/ledger/src/main.rs index 0ee413b71dd8..87560044321c 100644 --- a/rs/ledger_suite/icrc1/ledger/src/main.rs +++ b/rs/ledger_suite/icrc1/ledger/src/main.rs @@ -744,6 +744,7 @@ fn execute_transfer_not_async( from: from_account, spender, amount, + fee: None, }, created_at_time: created_at_time.map(|t| t.as_nanos_since_unix_epoch()), memo, diff --git a/rs/ledger_suite/icrc1/ledger/src/tests.rs b/rs/ledger_suite/icrc1/ledger/src/tests.rs index e51756af46b4..28bd3d8469e4 100644 --- a/rs/ledger_suite/icrc1/ledger/src/tests.rs +++ b/rs/ledger_suite/icrc1/ledger/src/tests.rs @@ -521,6 +521,7 @@ fn test_burn_smoke() { from, spender: None, amount: tokens(100_000), + fee: None, }, created_at_time: None, memo: None, @@ -550,6 +551,7 @@ fn test_approval_burn_from() { from, spender: Some(spender), amount: tokens(100_000), + fee: None, }, created_at_time: None, memo: None, @@ -585,6 +587,7 @@ fn test_approval_burn_from() { from, spender: Some(spender), amount: tokens(100_000), + fee: None, }, created_at_time: None, memo: None, @@ -609,6 +612,7 @@ fn test_approval_burn_from() { from, spender: Some(spender), amount: tokens(100_000), + fee: None, }, created_at_time: None, memo: None, @@ -671,3 +675,54 @@ fn arb_allowance() -> impl Strategy> { }, ) } + +#[test] +fn test_burn_fee_error() { + let now = ts(1); + + let mut ctx = Ledger::from_init_args(DummyLogger, default_init_args(), now); + + let from = test_account_id(1); + + ctx.balances_mut().mint(&from, tokens(200_000)).unwrap(); + + assert_eq!(tokens_to_u64(ctx.balances().total_supply()), 200_000); + + let tr = Transaction { + operation: Operation::Burn { + from, + spender: None, + amount: tokens(1_000), + fee: Some(tokens(10_000)), + }, + created_at_time: None, + memo: None, + }; + assert_eq!( + tr.apply(&mut ctx, now, Tokens::ZERO).unwrap_err(), + TxApplyError::BurnOrMintFee + ); +} + +#[test] +fn test_mint_fee_error() { + let now = ts(1); + + let mut ctx = Ledger::from_init_args(DummyLogger, default_init_args(), now); + + let to = test_account_id(1); + + let tr = Transaction { + operation: Operation::Mint { + to, + amount: tokens(1_000), + fee: Some(tokens(10_000)), + }, + created_at_time: None, + memo: None, + }; + assert_eq!( + tr.apply(&mut ctx, now, Tokens::ZERO).unwrap_err(), + TxApplyError::BurnOrMintFee + ); +} diff --git a/rs/ledger_suite/icrc1/ledger/tests/tests.rs b/rs/ledger_suite/icrc1/ledger/tests/tests.rs index 59602db92a1c..237b2c6cd328 100644 --- a/rs/ledger_suite/icrc1/ledger/tests/tests.rs +++ b/rs/ledger_suite/icrc1/ledger/tests/tests.rs @@ -314,6 +314,14 @@ fn test_mint_burn() { ic_ledger_suite_state_machine_tests::test_mint_burn(ledger_wasm(), encode_init_args); } +#[test] +fn test_mint_burn_fee_rejected() { + ic_ledger_suite_state_machine_tests::test_mint_burn_fee_rejected( + ledger_wasm(), + encode_init_args, + ); +} + #[test] fn test_anonymous_transfers() { ic_ledger_suite_state_machine_tests::test_anonymous_transfers(ledger_wasm(), encode_init_args); @@ -1189,6 +1197,7 @@ fn test_icrc3_get_archives() { operation: Operation::Mint { to: minting_account, amount: Tokens::from(1_000_000u64), + fee: None, }, created_at_time: None, memo: None, @@ -1935,6 +1944,7 @@ mod verify_written_blocks { to: mint_args.to, memo: mint_args.memo, created_at_time: mint_args.created_at_time, + fee: None, }); } @@ -2049,6 +2059,7 @@ mod verify_written_blocks { spender: Some(spender_account), memo: burn_args.memo, created_at_time: burn_args.created_at_time, + fee: None, }); } diff --git a/rs/ledger_suite/icrc1/src/endpoints.rs b/rs/ledger_suite/icrc1/src/endpoints.rs index 17cb893f0656..4930df6f1902 100644 --- a/rs/ledger_suite/icrc1/src/endpoints.rs +++ b/rs/ledger_suite/icrc1/src/endpoints.rs @@ -165,19 +165,21 @@ impl From> for Transaction { let memo = b.transaction.memo; match b.transaction.operation { - Operation::Mint { to, amount } => { + Operation::Mint { to, amount, fee } => { tx.kind = "mint".to_string(); tx.mint = Some(Mint { to, amount: amount.into(), created_at_time, memo, + fee: fee.map(Into::into), }); } Operation::Burn { from, spender, amount, + fee, } => { tx.kind = "burn".to_string(); tx.burn = Some(Burn { @@ -186,6 +188,7 @@ impl From> for Transaction { amount: amount.into(), created_at_time, memo, + fee: fee.map(Into::into), }); } Operation::Transfer { diff --git a/rs/ledger_suite/icrc1/src/lib.rs b/rs/ledger_suite/icrc1/src/lib.rs index 8655479026c1..7389b917586e 100644 --- a/rs/ledger_suite/icrc1/src/lib.rs +++ b/rs/ledger_suite/icrc1/src/lib.rs @@ -30,6 +30,8 @@ pub enum Operation { to: Account, #[serde(rename = "amt")] amount: Tokens, + #[serde(skip_serializing_if = "Option::is_none")] + fee: Option, }, #[serde(rename = "xfer")] Transfer { @@ -60,6 +62,8 @@ pub enum Operation { spender: Option, #[serde(rename = "amt")] amount: Tokens, + #[serde(skip_serializing_if = "Option::is_none")] + fee: Option, }, #[serde(rename = "approve")] Approve { @@ -140,10 +144,12 @@ impl TryFrom> for Transaction Operation::Mint { to: value.to.ok_or("`to` field required for `mint` operation")?, - amount: value.amount, + amount: value.amount.clone(), + fee: value.fee, }, "xfer" => Operation::Transfer { from: value @@ -210,8 +216,10 @@ impl From> for FlattenedTransaction amount.clone(), }, fee: match &t.operation { - Transfer { fee, .. } | Approve { fee, .. } => fee.to_owned(), - _ => None, + Transfer { fee, .. } + | Approve { fee, .. } + | Mint { fee, .. } + | Burn { fee, .. } => fee.to_owned(), }, expected_allowance: match &t.operation { Approve { @@ -253,6 +261,7 @@ impl LedgerTransaction for Transaction { from, spender, amount, + fee: None, }, created_at_time: created_at_time.map(|t| t.as_nanos_since_unix_epoch()), memo: memo.map(Memo::from), @@ -355,7 +364,11 @@ impl LedgerTransaction for Transaction { from, spender, amount, + fee, } => { + if fee.is_some() { + return Err(TxApplyError::BurnOrMintFee); + } if spender.is_some() && from != &spender.unwrap() { let allowance = context.approvals().allowance(from, &spender.unwrap(), now); if allowance.amount < *amount { @@ -372,7 +385,12 @@ impl LedgerTransaction for Transaction { .expect("bug: cannot use allowance"); } } - Operation::Mint { to, amount } => context.balances_mut().mint(to, amount.clone())?, + Operation::Mint { to, amount, fee } => { + if fee.is_some() { + return Err(TxApplyError::BurnOrMintFee); + } + context.balances_mut().mint(to, amount.clone())?; + } Operation::Approve { from, spender, @@ -416,7 +434,11 @@ impl Transaction { memo: Option, ) -> Self { Self { - operation: Operation::Mint { to, amount }, + operation: Operation::Mint { + to, + amount, + fee: None, + }, created_at_time: created_at_time.map(|t| t.as_nanos_since_unix_epoch()), memo, } @@ -455,9 +477,17 @@ impl TryFrom Some( + Tokens::try_from(fee) + .map_err(|_| "Could not convert Nat to Tokens".to_string())?, + ), + None => None, + }; let operation = Operation::Mint { to: mint.to, amount, + fee, }; return Ok(Self { operation, @@ -468,10 +498,18 @@ impl TryFrom Some( + Tokens::try_from(fee) + .map_err(|_| "Could not convert Nat to Tokens".to_string())?, + ), + None => None, + }; let operation = Operation::Burn { from: burn.from, spender: burn.spender, amount, + fee, }; return Ok(Self { operation, diff --git a/rs/ledger_suite/icrc1/test_utils/icrc3_test_ledger/tests/tests.rs b/rs/ledger_suite/icrc1/test_utils/icrc3_test_ledger/tests/tests.rs index e897b2ef17ce..1c98f7640959 100644 --- a/rs/ledger_suite/icrc1/test_utils/icrc3_test_ledger/tests/tests.rs +++ b/rs/ledger_suite/icrc1/test_utils/icrc3_test_ledger/tests/tests.rs @@ -9,9 +9,9 @@ use ic_icrc1::endpoints::StandardRecord; use ic_icrc1_index_ng::{IndexArg, InitArg}; use ic_icrc1_ledger::Tokens; use ic_icrc1_test_utils::icrc3::BlockBuilder; -use ic_icrc3_test_ledger::{AddBlockResult, ArchiveBlocksArgs}; use ic_ledger_suite_state_machine_helpers::{ - balance_of, icrc3_get_blocks as icrc3_get_blocks_helper, + add_block, archive_blocks, balance_of, icrc3_get_blocks as icrc3_get_blocks_helper, + set_icrc3_enabled, }; use ic_state_machine_tests::StateMachine; use ic_test_utilities_load_wasm::load_wasm; @@ -60,20 +60,6 @@ fn setup_icrc3_test_ledger() -> (StateMachine, CanisterId) { (env, canister_id) } -fn add_block( - env: &StateMachine, - canister_id: CanisterId, - block: &ICRC3Value, -) -> Result { - Decode!( - &env.execute_ingress(canister_id, "add_block", Encode!(block).unwrap()) - .expect("failed to add block") - .bytes(), - AddBlockResult - ) - .expect("failed to decode add_block response") -} - fn icrc3_get_blocks( env: &StateMachine, canister_id: CanisterId, @@ -106,26 +92,6 @@ fn get_blocks( .expect("failed to decode icrc3_get_blocks response") } -fn archive_blocks( - env: &StateMachine, - ledger_id: CanisterId, - archive_id: CanisterId, - num_blocks: u64, -) -> u64 { - let archive_args = ArchiveBlocksArgs { - archive_id: archive_id.into(), - num_blocks, - }; - Decode!( - &env.execute_ingress(ledger_id, "archive_blocks", Encode!(&archive_args).unwrap()) - .expect("failed to archive blocks") - .bytes(), - Result - ) - .expect("failed to decode archive_blocks response") - .expect("archiving blocks operation failed") -} - fn check_legacy_get_blocks( env: &StateMachine, canister_id: CanisterId, @@ -574,16 +540,6 @@ fn get_supported_standards(env: &StateMachine, canister_id: CanisterId) -> Vec( amount_strategy.prop_flat_map(|amount| { // Clone amount due to move let mint_amount = amount.clone(); - let mint_strategy = account_strategy().prop_map(move |to| Operation::Mint { - to, - amount: mint_amount.clone(), - }); + let mint_strategy = ( + account_strategy(), + prop::option::of(Just(token_amount(DEFAULT_TRANSFER_FEE))), + ) + .prop_map(move |(to, fee)| Operation::Mint { + to, + amount: mint_amount.clone(), + fee, + }); let burn_amount = amount.clone(); - let burn_strategy = account_strategy().prop_map(move |from| Operation::Burn { - from, - spender: None, - amount: burn_amount.clone(), - }); + let burn_strategy = ( + account_strategy(), + prop::option::of(Just(token_amount(DEFAULT_TRANSFER_FEE))), + ) + .prop_map(move |(from, fee)| Operation::Burn { + from, + spender: None, + amount: burn_amount.clone(), + fee, + }); let transfer_amount = amount.clone(); let transfer_strategy = ( account_strategy(), @@ -211,8 +221,8 @@ pub fn blocks_strategy( let effective_fee = match transaction.operation { Operation::Transfer { ref fee, .. } => fee.clone().is_none().then_some(arb_fee), Operation::Approve { ref fee, .. } => fee.clone().is_none().then_some(arb_fee), - Operation::Burn { .. } => None, - Operation::Mint { .. } => None, + Operation::Burn { ref fee, .. } => fee.clone().is_none().then_some(arb_fee), + Operation::Mint { ref fee, .. } => fee.clone().is_none().then_some(arb_fee), }; Block { @@ -418,12 +428,14 @@ impl ArgWithCaller { Operation::Mint { amount: T::try_from(transfer_arg.amount.clone()).unwrap(), to: transfer_arg.to, + fee: transfer_arg.fee.clone().map(|f| T::try_from(f).unwrap()), } } else if burn_operation { Operation::Burn { amount: T::try_from(transfer_arg.amount.clone()).unwrap(), from: caller, spender: None, + fee: transfer_arg.fee.clone().map(|f| T::try_from(f).unwrap()), } } else { Operation::Transfer { @@ -770,6 +782,7 @@ pub fn valid_transactions_strategy_with_excluded_transaction_types( operation: Operation::Mint:: { amount: Tokens::from_e8s(amount), to, + fee: None, }, created_at_time, memo: memo.clone(), @@ -832,6 +845,7 @@ pub fn valid_transactions_strategy_with_excluded_transaction_types( amount: Tokens::from_e8s(amount), from, spender: None, + fee: None, }, created_at_time, memo: memo.clone(), @@ -1394,7 +1408,12 @@ where Tokens: TokensType, S: Strategy, { - (arb_account(), arb_tokens()).prop_map(|(to, amount)| Operation::Mint { to, amount }) + ( + arb_account(), + arb_tokens(), + proptest::option::of(arb_tokens()), + ) + .prop_map(|(to, amount, fee)| Operation::Mint { to, amount, fee }) } pub fn arb_burn(arb_tokens: fn() -> S) -> impl Strategy> @@ -1406,11 +1425,13 @@ where arb_account(), proptest::option::of(arb_account()), arb_tokens(), + proptest::option::of(arb_tokens()), ) - .prop_map(|(from, spender, amount)| Operation::Burn { + .prop_map(|(from, spender, amount, fee)| Operation::Burn { from, spender, amount, + fee, }) } diff --git a/rs/ledger_suite/test_utils/in_memory_ledger/src/lib.rs b/rs/ledger_suite/test_utils/in_memory_ledger/src/lib.rs index 98e31824bef5..f7a18a09f146 100644 --- a/rs/ledger_suite/test_utils/in_memory_ledger/src/lib.rs +++ b/rs/ledger_suite/test_utils/in_memory_ledger/src/lib.rs @@ -496,7 +496,10 @@ where self.fee_collector = Some(fee_collector); } match &block.transaction.operation { - Operation::Mint { to, amount } => self.process_mint(to, amount), + Operation::Mint { to, amount, fee } => { + assert!(fee.is_none()); + self.process_mint(to, amount); + } Operation::Transfer { from, to, @@ -514,7 +517,11 @@ where from, spender, amount, - } => self.process_burn(from, spender, amount, index), + fee, + } => { + assert!(fee.is_none()); + self.process_burn(from, spender, amount, index); + } Operation::Approve { from, spender, diff --git a/rs/ledger_suite/test_utils/state_machine_helpers/BUILD.bazel b/rs/ledger_suite/test_utils/state_machine_helpers/BUILD.bazel index 879c4ccd7846..a7d0fb2d3cb4 100644 --- a/rs/ledger_suite/test_utils/state_machine_helpers/BUILD.bazel +++ b/rs/ledger_suite/test_utils/state_machine_helpers/BUILD.bazel @@ -9,6 +9,7 @@ DEPENDENCIES = [ "//rs/ledger_suite/common/ledger_core", "//rs/ledger_suite/icp:icp_ledger", "//rs/ledger_suite/icrc1", + "//rs/ledger_suite/icrc1/test_utils/icrc3_test_ledger", "//rs/state_machine_tests", "//rs/types/base_types", "//rs/types/management_canister_types", diff --git a/rs/ledger_suite/test_utils/state_machine_helpers/Cargo.toml b/rs/ledger_suite/test_utils/state_machine_helpers/Cargo.toml index 827c5d9c5855..49db6096c594 100644 --- a/rs/ledger_suite/test_utils/state_machine_helpers/Cargo.toml +++ b/rs/ledger_suite/test_utils/state_machine_helpers/Cargo.toml @@ -11,6 +11,7 @@ candid = { workspace = true } ic-base-types = { path = "../../../types/base_types" } ic-http-types = { path = "../../../../packages/ic-http-types" } ic-icrc1 = { path = "../../icrc1" } +ic-icrc3-test-ledger = { path = "../../icrc1/test_utils/icrc3_test_ledger" } ic-ledger-core = { path = "../../common/ledger_core" } ic-management-canister-types-private = { path = "../../../types/management_canister_types" } ic-state-machine-tests = { path = "../../../state_machine_tests" } diff --git a/rs/ledger_suite/test_utils/state_machine_helpers/src/lib.rs b/rs/ledger_suite/test_utils/state_machine_helpers/src/lib.rs index a9b76ee52b2c..2693f3c4c5fc 100644 --- a/rs/ledger_suite/test_utils/state_machine_helpers/src/lib.rs +++ b/rs/ledger_suite/test_utils/state_machine_helpers/src/lib.rs @@ -3,6 +3,7 @@ use ic_base_types::CanisterId; use ic_base_types::PrincipalId; use ic_http_types::{HttpRequest, HttpResponse}; use ic_icrc1::{Block, endpoints::StandardRecord}; +use ic_icrc3_test_ledger::{AddBlockResult, ArchiveBlocksArgs}; use ic_ledger_core::Tokens; use ic_ledger_core::block::BlockIndex; use ic_ledger_core::tokens::TokensType; @@ -14,6 +15,7 @@ use ic_types::Cycles; use ic_universal_canister::{call_args, wasm}; use icp_ledger::{AccountIdentifier, BinaryAccountBalanceArgs, IcpAllowanceArgs}; use icrc_ledger_types::icrc::generic_metadata_value::MetadataValue as Value; +use icrc_ledger_types::icrc::generic_value::ICRC3Value; use icrc_ledger_types::icrc1::account::Account; use icrc_ledger_types::icrc1::transfer::{Memo, TransferArg, TransferError}; use icrc_ledger_types::icrc2::allowance::{Allowance, AllowanceArgs}; @@ -824,3 +826,47 @@ fn universal_canister_payload( ) .build() } + +pub fn archive_blocks( + env: &StateMachine, + ledger_id: CanisterId, + archive_id: CanisterId, + num_blocks: u64, +) -> u64 { + let archive_args = ArchiveBlocksArgs { + archive_id: archive_id.into(), + num_blocks, + }; + Decode!( + &env.execute_ingress(ledger_id, "archive_blocks", Encode!(&archive_args).unwrap()) + .expect("failed to archive blocks") + .bytes(), + Result + ) + .expect("failed to decode archive_blocks response") + .expect("archiving blocks operation failed") +} + +pub fn add_block( + env: &StateMachine, + canister_id: CanisterId, + block: &ICRC3Value, +) -> Result { + Decode!( + &env.execute_ingress(canister_id, "add_block", Encode!(block).unwrap()) + .expect("failed to add block") + .bytes(), + AddBlockResult + ) + .expect("failed to decode add_block response") +} + +pub fn set_icrc3_enabled(env: &StateMachine, canister_id: CanisterId, enabled: bool) { + Decode!( + &env.execute_ingress(canister_id, "set_icrc3_enabled", Encode!(&enabled).unwrap()) + .expect("failed to set_icrc3_enabled") + .bytes(), + () + ) + .expect("failed to decode set_icrc3_enabled response") +} diff --git a/rs/ledger_suite/tests/sm-tests/src/lib.rs b/rs/ledger_suite/tests/sm-tests/src/lib.rs index ae961229a3f0..2deb5e5ba24d 100644 --- a/rs/ledger_suite/tests/sm-tests/src/lib.rs +++ b/rs/ledger_suite/tests/sm-tests/src/lib.rs @@ -286,7 +286,12 @@ fn arb_approve() -> impl Strategy> } fn arb_mint() -> impl Strategy> { - (arb_account(), arb_amount()).prop_map(|(to, amount)| Operation::Mint { to, amount }) + ( + arb_account(), + arb_amount(), + proptest::option::of(arb_amount()), + ) + .prop_map(|(to, amount, fee)| Operation::Mint { to, amount, fee }) } fn arb_burn() -> impl Strategy> { @@ -294,11 +299,13 @@ fn arb_burn() -> impl Strategy> { arb_account(), proptest::option::of(arb_account()), arb_amount(), + proptest::option::of(arb_amount()), ) - .prop_map(|(from, spender, amount)| Operation::Burn { + .prop_map(|(from, spender, amount, fee)| Operation::Burn { from, spender, amount, + fee, }) } @@ -963,6 +970,112 @@ where ); } +pub fn test_mint_burn_fee_rejected(ledger_wasm: Vec, encode_init_args: fn(InitArgs) -> T) +where + T: CandidType, +{ + let (env, canister_id) = setup(ledger_wasm, encode_init_args, vec![]); + let p1 = PrincipalId::new_user_test_id(1); + let p2 = PrincipalId::new_user_test_id(2); + + assert_eq!(0, total_supply(&env, canister_id)); + assert_eq!(0, balance_of(&env, canister_id, p1.0)); + assert_eq!(0, balance_of(&env, canister_id, MINTER)); + + const INITIAL_BALANCE: u64 = 10_000_000; + const TX_AMOUNT: u64 = 1_000_000; + + let mint_error = send_transfer( + &env, + canister_id, + MINTER.owner, + &TransferArg { + from_subaccount: None, + to: p1.0.into(), + fee: Some(FEE.into()), + created_at_time: None, + amount: Nat::from(INITIAL_BALANCE), + memo: None, + }, + ) + .unwrap_err(); + assert_eq!( + mint_error, + TransferError::BadFee { + expected_fee: Nat::from(0u64) + } + ); + + transfer(&env, canister_id, MINTER, p1.0, INITIAL_BALANCE).expect("mint failed"); + + let mut expected_balance = INITIAL_BALANCE; + + assert_eq!(expected_balance, total_supply(&env, canister_id)); + assert_eq!(expected_balance, balance_of(&env, canister_id, p1.0)); + assert_eq!(0, balance_of(&env, canister_id, MINTER)); + + let burn_error = send_transfer( + &env, + canister_id, + p1.0, + &TransferArg { + from_subaccount: None, + to: MINTER, + fee: Some(FEE.into()), + created_at_time: None, + amount: Nat::from(TX_AMOUNT), + memo: None, + }, + ) + .unwrap_err(); + assert_eq!( + burn_error, + TransferError::BadFee { + expected_fee: Nat::from(0u64) + } + ); + + transfer(&env, canister_id, p1.0, MINTER, TX_AMOUNT).expect("burn failed"); + + expected_balance -= TX_AMOUNT; + + assert_eq!(expected_balance, total_supply(&env, canister_id)); + assert_eq!(expected_balance, balance_of(&env, canister_id, p1.0)); + assert_eq!(0, balance_of(&env, canister_id, MINTER)); + + let approve_args = default_approve_args(p2.0, u64::MAX); + send_approval(&env, canister_id, p1.into(), &approve_args).expect("approval failed"); + + expected_balance -= FEE; + + let mut transfer_from_args = TransferFromArgs { + from: p1.0.into(), + to: MINTER, + fee: Some(FEE.into()), + created_at_time: None, + amount: Nat::from(TX_AMOUNT), + memo: None, + spender_subaccount: None, + }; + let burn_from_error = + send_transfer_from(&env, canister_id, p2.0, &transfer_from_args).unwrap_err(); + assert_eq!( + burn_from_error, + TransferFromError::BadFee { + expected_fee: Nat::from(0u64) + } + ); + + transfer_from_args.fee = None; + send_transfer_from(&env, canister_id, p2.0, &transfer_from_args).expect("transfer from failed"); + + expected_balance -= TX_AMOUNT; + + assert_eq!(expected_balance, total_supply(&env, canister_id)); + assert_eq!(expected_balance, balance_of(&env, canister_id, p1.0)); + assert_eq!(0, balance_of(&env, canister_id, MINTER)); +} + pub fn test_account_canonicalization(ledger_wasm: Vec, encode_init_args: fn(InitArgs) -> T) where T: CandidType, diff --git a/rs/nervous_system/initial_supply/src/tests.rs b/rs/nervous_system/initial_supply/src/tests.rs index 4bb2f4946c13..baa9a761bc00 100644 --- a/rs/nervous_system/initial_supply/src/tests.rs +++ b/rs/nervous_system/initial_supply/src/tests.rs @@ -68,6 +68,7 @@ async fn test_initial_supply() { subaccount: None, }, memo: None, + fee: None, }), kind: "mint".to_string(), diff --git a/rs/rosetta-api/icrc1/src/common/storage/storage_operations.rs b/rs/rosetta-api/icrc1/src/common/storage/storage_operations.rs index 32ab8e1b78c9..71018261c4d6 100644 --- a/rs/rosetta-api/icrc1/src/common/storage/storage_operations.rs +++ b/rs/rosetta-api/icrc1/src/common/storage/storage_operations.rs @@ -268,7 +268,12 @@ pub fn update_account_balances(connection: &mut Connection) -> anyhow::Result<() while !rosetta_blocks.is_empty() { for rosetta_block in rosetta_blocks { match rosetta_block.get_transaction().operation { - crate::common::storage::types::IcrcOperation::Burn { from, amount, .. } => { + crate::common::storage::types::IcrcOperation::Burn { + from, + amount, + fee: _, + spender: _, + } => { let fee = rosetta_block .get_fee_paid()? .unwrap_or(Nat(BigUint::zero())); @@ -295,7 +300,7 @@ pub fn update_account_balances(connection: &mut Connection) -> anyhow::Result<() )?; } } - crate::common::storage::types::IcrcOperation::Mint { to, amount } => { + crate::common::storage::types::IcrcOperation::Mint { to, amount, fee: _ } => { let fee = rosetta_block .get_fee_paid()? .unwrap_or(Nat(BigUint::zero())); @@ -322,7 +327,14 @@ pub fn update_account_balances(connection: &mut Connection) -> anyhow::Result<() )?; } } - crate::common::storage::types::IcrcOperation::Approve { from, .. } => { + crate::common::storage::types::IcrcOperation::Approve { + from, + spender: _, + amount: _, + expected_allowance: _, + expires_at: _, + fee: _, + } => { let fee = rosetta_block .get_fee_paid()? .unwrap_or(Nat(BigUint::zero())); @@ -335,7 +347,11 @@ pub fn update_account_balances(connection: &mut Connection) -> anyhow::Result<() )?; } crate::common::storage::types::IcrcOperation::Transfer { - from, to, amount, .. + from, + to, + amount, + spender: _, + fee: _, } => { let fee = rosetta_block .get_fee_paid()? @@ -423,7 +439,7 @@ pub fn store_blocks( fee, approval_expires_at, ) = match transaction.operation { - crate::common::storage::types::IcrcOperation::Mint { to, amount } => ( + crate::common::storage::types::IcrcOperation::Mint { to, amount, fee } => ( "mint", None, None, @@ -433,7 +449,7 @@ pub fn store_blocks( None, amount, None, - None, + fee, None, ), crate::common::storage::types::IcrcOperation::Transfer { @@ -455,7 +471,9 @@ pub fn store_blocks( fee, None, ), - crate::common::storage::types::IcrcOperation::Burn { from, amount, .. } => ( + crate::common::storage::types::IcrcOperation::Burn { + from, amount, fee, .. + } => ( "burn", Some(from.owner), Some(*from.effective_subaccount()), @@ -465,7 +483,7 @@ pub fn store_blocks( None, amount, None, - None, + fee, None, ), crate::common::storage::types::IcrcOperation::Approve { diff --git a/rs/rosetta-api/icrc1/src/common/storage/storage_operations_test.rs b/rs/rosetta-api/icrc1/src/common/storage/storage_operations_test.rs index 5d752921bdb5..fea9e5b58ff7 100644 --- a/rs/rosetta-api/icrc1/src/common/storage/storage_operations_test.rs +++ b/rs/rosetta-api/icrc1/src/common/storage/storage_operations_test.rs @@ -377,6 +377,7 @@ fn test_fee_collector_resolution_and_repair() -> anyhow::Result<()> { mint_block.block.transaction.operation = IcrcOperation::Mint { to: from_account, amount: Nat::from(1000000000u64), + fee: None, }; let mut block1 = create_test_rosetta_block(1, 1000000000, &principal1, 100); @@ -512,6 +513,7 @@ fn test_repair_fee_collector_edge_cases() -> anyhow::Result<()> { mint_block.block.transaction.operation = IcrcOperation::Mint { to: from_account, amount: Nat::from(1000000000u64), + fee: None, }; store_blocks(&mut connection, vec![mint_block])?; diff --git a/rs/rosetta-api/icrc1/src/common/storage/types.rs b/rs/rosetta-api/icrc1/src/common/storage/types.rs index 89b8ab031633..b0ac9bc66d32 100644 --- a/rs/rosetta-api/icrc1/src/common/storage/types.rs +++ b/rs/rosetta-api/icrc1/src/common/storage/types.rs @@ -134,10 +134,10 @@ impl RosettaBlock { Ok(self .get_effective_fee() .or(match self.get_transaction().operation { - IcrcOperation::Mint { .. } => None, + IcrcOperation::Mint { fee, .. } => fee, IcrcOperation::Transfer { fee, .. } => fee, IcrcOperation::Approve { fee, .. } => fee, - IcrcOperation::Burn { .. } => None, + IcrcOperation::Burn { fee, .. } => fee, })) } @@ -338,6 +338,7 @@ pub enum IcrcOperation { Mint { to: Account, amount: Nat, + fee: Option, }, Transfer { from: Account, @@ -350,6 +351,7 @@ pub enum IcrcOperation { from: Account, spender: Option, amount: Nat, + fee: Option, }, Approve { from: Account, @@ -376,11 +378,12 @@ impl TryFrom> for IcrcOperation { from, spender, amount, + fee, }) } "mint" => { let to: Account = get_field(&map, FIELD_PREFIX, "to")?; - Ok(Self::Mint { to, amount }) + Ok(Self::Mint { to, amount, fee }) } "xfer" => { let from: Account = get_field(&map, FIELD_PREFIX, "from")?; @@ -452,6 +455,7 @@ impl From for BTreeMap { from, spender, amount, + fee, } => { map.insert("op".to_string(), Value::text("burn")); map.insert("from".to_string(), Value::from(from)); @@ -459,11 +463,17 @@ impl From for BTreeMap { map.insert("spender".to_string(), Value::from(spender)); } map.insert("amt".to_string(), Value::Nat(amount)); + if let Some(fee) = fee { + map.insert("fee".to_string(), Value::Nat(fee)); + } } - Op::Mint { to, amount } => { + Op::Mint { to, amount, fee } => { map.insert("op".to_string(), Value::text("mint")); map.insert("to".to_string(), Value::from(to)); map.insert("amt".to_string(), Value::Nat(amount)); + if let Some(fee) = fee { + map.insert("fee".to_string(), Value::Nat(fee)); + } } Op::Transfer { from, @@ -571,14 +581,17 @@ where from, spender, amount, + fee, } => Self::Burn { from, spender, amount: amount.into(), + fee: fee.map(Into::into), }, - Op::Mint { to, amount } => Self::Mint { + Op::Mint { to, amount, fee } => Self::Mint { to, amount: amount.into(), + fee: fee.map(Into::into), }, Op::Transfer { from, @@ -677,20 +690,23 @@ mod tests { arb_account(), // from option::of(arb_account()), // spender arb_nat(), // amount + option::of(arb_nat()), // fee ) - .prop_map(|(from, spender, amount)| IcrcOperation::Burn { + .prop_map(|(from, spender, amount, fee)| IcrcOperation::Burn { from, spender, amount, + fee, }) } fn arb_mint() -> impl Strategy { ( - arb_account(), // to - arb_nat(), // amount + arb_account(), // to + arb_nat(), // amount + option::of(arb_nat()), // fee ) - .prop_map(|(to, amount)| IcrcOperation::Mint { to, amount }) + .prop_map(|(to, amount, fee)| IcrcOperation::Mint { to, amount, fee }) } fn arb_transfer() -> impl Strategy { @@ -903,26 +919,31 @@ mod tests { from, spender, amount, + fee, }, IcrcOperation::Burn { from: rosetta_from, spender: rosetta_spender, amount: rosetta_amount, + fee: rosetta_fee, }, ) => { assert_eq!(from, rosetta_from, "from"); assert_eq!(spender, rosetta_spender, "spender"); assert_eq!(amount.into(), rosetta_amount, "amount"); + assert_eq!(fee.map(|t| t.into()), rosetta_fee, "fee"); } ( - ic_icrc1::Operation::Mint { to, amount }, + ic_icrc1::Operation::Mint { to, amount, fee }, IcrcOperation::Mint { to: rosetta_to, amount: rosetta_amount, + fee: rosetta_fee, }, ) => { assert_eq!(to, rosetta_to, "to"); assert_eq!(amount.into(), rosetta_amount, "amount"); + assert_eq!(fee.map(|t| t.into()), rosetta_fee, "fee"); } ( ic_icrc1::Operation::Transfer { diff --git a/rs/rosetta-api/icrc1/src/common/utils/utils.rs b/rs/rosetta-api/icrc1/src/common/utils/utils.rs index 6419d07d3241..e522537b919e 100644 --- a/rs/rosetta-api/icrc1/src/common/utils/utils.rs +++ b/rs/rosetta-api/icrc1/src/common/utils/utils.rs @@ -233,24 +233,20 @@ pub fn rosetta_core_operations_to_icrc1_operation( if self.spender.is_some() { bail!("Spender AccountIdentifier field is not allowed for Mint operation") } - if self.fee.is_some() { - bail!("Fee field is not allowed for Mint operation") - } crate::common::storage::types::IcrcOperation::Mint{ to: self.to.context("Account field needs to be populated for Mint operation")?.try_into()?, amount: self.amount.context("Amount field needs to be populated for Mint operation")?, + fee: self.fee, }}, IcrcOperation::Burn => { if self.to.is_some() { bail!("To AccountIdentifier field is not allowed for Burn operation") } - if self.fee.is_some() { - bail!("Fee field is not allowed for Burn operation") - } crate::common::storage::types::IcrcOperation::Burn{ from: self.from.context("From AccountIdentifier field needs to be populated for Burn operation")?.try_into()?, amount: self.amount.context("Amount field needs to be populated for Burn operation")?, spender: self.spender.map(|spender| spender.try_into()).transpose()?, + fee: self.fee, }}, IcrcOperation::Transfer => crate::common::storage::types::IcrcOperation::Transfer{ from: self.from.context("From AccountIdentifier field needs to be populated for Transfer operation")?.try_into()?, @@ -392,24 +388,48 @@ pub fn icrc1_operation_to_rosetta_core_operations( ) -> anyhow::Result> { let mut operations = vec![]; match operation { - crate::common::storage::types::IcrcOperation::Mint { to, amount } => { + crate::common::storage::types::IcrcOperation::Mint { to, amount, fee } => { operations.push(rosetta_core::objects::Operation::new( 0, OperationType::Mint.to_string(), Some(to.into()), Some(rosetta_core::objects::Amount::new( BigInt::from(amount.0), - currency, + currency.clone(), )), None, None, - )) + )); + + if let Some(fee_paid) = fee_payed { + operations.push(rosetta_core::objects::Operation::new( + 1, + OperationType::Fee.to_string(), + Some(to.into()), // Mint fees are payed by the receiving account. + Some(Amount::new( + BigInt::from_biguint(num_bigint::Sign::Minus, fee_paid.0), + currency, + )), + None, + // If the fee inside the operation is set that means the User set the fee and the Ledger did nothing + Some( + FeeMetadata { + fee_set_by: match fee { + Some(_) => FeeSetter::User, + None => FeeSetter::Ledger, + }, + } + .try_into()?, + ), + )); + } } crate::common::storage::types::IcrcOperation::Burn { from, spender, amount, + fee, } => { operations.push(rosetta_core::objects::Operation::new( 0, @@ -417,21 +437,47 @@ pub fn icrc1_operation_to_rosetta_core_operations( Some(from.into()), Some(rosetta_core::objects::Amount::new( BigInt::from_biguint(num_bigint::Sign::Minus, amount.0), - currency, + currency.clone(), )), None, None, )); + let mut idx = 1; + if let Some(spender) = spender { operations.push(rosetta_core::objects::Operation::new( - 1, + idx, OperationType::Spender.to_string(), Some(spender.into()), None, None, None, )); + idx += 1; + } + + if let Some(fee_paid) = fee_payed { + operations.push(rosetta_core::objects::Operation::new( + idx, + OperationType::Fee.to_string(), + Some(from.into()), + Some(Amount::new( + BigInt::from_biguint(num_bigint::Sign::Minus, fee_paid.0), + currency, + )), + None, + // If the fee inside the operation is set that means the User set the fee and the Ledger did nothing + Some( + FeeMetadata { + fee_set_by: match fee { + Some(_) => FeeSetter::User, + None => FeeSetter::Ledger, + }, + } + .try_into()?, + ), + )); } } @@ -487,7 +533,7 @@ pub fn icrc1_operation_to_rosetta_core_operations( Some(from.into()), Some(Amount::new( BigInt::from_biguint(num_bigint::Sign::Minus, fee_paid.0), - currency.clone(), + currency, )), None, // If the fee inside the operation is set that means the User set the fee and the Ledger did nothing diff --git a/rs/rosetta-api/icrc1/src/data_api/services.rs b/rs/rosetta-api/icrc1/src/data_api/services.rs index 6f688d2a2055..d622f755177e 100644 --- a/rs/rosetta-api/icrc1/src/data_api/services.rs +++ b/rs/rosetta-api/icrc1/src/data_api/services.rs @@ -1536,6 +1536,7 @@ mod test { operation: IcrcOperation::Mint { to: main_account, amount: Nat::from(1000u64), + fee: None, }, created_at_time: Some(1000), memo: None, @@ -1603,6 +1604,7 @@ mod test { operation: IcrcOperation::Mint { to: main_account, amount: Nat::from(500u64), + fee: None, }, created_at_time: Some(1000), memo: None, @@ -1621,6 +1623,7 @@ mod test { operation: IcrcOperation::Mint { to: account1, amount: Nat::from(1000u64), + fee: None, }, created_at_time: Some(2000), memo: None, @@ -1770,6 +1773,7 @@ mod test { operation: IcrcOperation::Mint { to: main_account, amount: Nat::from(1000u64), + fee: None, }, created_at_time: Some(1000), memo: None, @@ -2125,6 +2129,7 @@ mod test { operation: IcrcOperation::Mint { to: main_account, amount: Nat::from(6000000u64), // 0.06 tokens + fee: None, }, created_at_time: Some(1), memo: None, @@ -2144,6 +2149,7 @@ mod test { operation: IcrcOperation::Mint { to: explicit_zero_account, amount: Nat::from(1000000u64), // 0.01 tokens + fee: None, }, created_at_time: Some(2), memo: None, @@ -2163,6 +2169,7 @@ mod test { operation: IcrcOperation::Mint { to: account1, amount: Nat::from(1000000u64), // 0.01 tokens + fee: None, }, created_at_time: Some(3), memo: None, @@ -2242,4 +2249,122 @@ mod test { ); } } + + #[test] + fn test_mint_and_burn_fees() { + use crate::common::storage::types::{ + IcrcBlock, IcrcOperation, IcrcTransaction, RosettaBlock, + }; + use candid::{Nat, Principal}; + use icrc_ledger_types::icrc1::account::Account; + use rosetta_core::identifiers::AccountIdentifier; + + let storage_client = StorageClient::new_in_memory().unwrap(); + let symbol = "ICP"; + let decimals = 8; + + let principal = Principal::anonymous(); + + // First, add some blocks to the database so we can test the validation logic + let main_account = Account { + owner: principal, + subaccount: None, + }; + let main_account_id = AccountIdentifier::from(main_account); + + let add_mint_block = + |block_id: u64, amount: u64, fee: Option, effective_fee: Option| { + let blocks = vec![RosettaBlock::from_icrc_ledger_block( + IcrcBlock { + parent_hash: None, + transaction: IcrcTransaction { + operation: IcrcOperation::Mint { + to: main_account, + amount: Nat::from(amount), + fee: fee.map(Into::into), + }, + created_at_time: None, + memo: None, + }, + effective_fee: effective_fee.map(Into::into), + timestamp: 1, + fee_collector: None, + fee_collector_block_index: None, + }, + block_id, + )]; + + storage_client.store_blocks(blocks).unwrap(); + storage_client.update_account_balances().unwrap(); + }; + + let add_burn_block = + |block_id: u64, amount: u64, fee: Option, effective_fee: Option| { + let blocks = vec![RosettaBlock::from_icrc_ledger_block( + IcrcBlock { + parent_hash: None, + transaction: IcrcTransaction { + operation: IcrcOperation::Burn { + from: main_account, + amount: Nat::from(amount), + fee: fee.map(Into::into), + spender: None, + }, + created_at_time: None, + memo: None, + }, + effective_fee: effective_fee.map(Into::into), + timestamp: 1, + fee_collector: None, + fee_collector_block_index: None, + }, + block_id, + )]; + + storage_client.store_blocks(blocks).unwrap(); + storage_client.update_account_balances().unwrap(); + }; + + let check_account_balance = |expected_balance: &str| { + let result = account_balance( + &storage_client, + &main_account_id, + &None, + decimals, + symbol.to_string(), + ); + + assert!(result.is_ok()); + let balance_response = result.unwrap(); + assert_eq!(balance_response.balances.len(), 1); + assert_eq!( + balance_response.balances[0].value.to_string(), + expected_balance + ); + }; + + // The operation fee of 100 is applied + add_mint_block(0, 1000, Some(100), None); + check_account_balance("900"); + add_burn_block(1, 100, Some(100), None); + check_account_balance("700"); + + // The block effective_fee of 100 is applied + add_mint_block(2, 200, Some(200), Some(100)); + check_account_balance("800"); + add_burn_block(3, 200, Some(200), Some(100)); + check_account_balance("500"); + + // The block effective_fee of 100 is applied + add_mint_block(4, 200, None, Some(100)); + check_account_balance("600"); + add_burn_block(5, 200, None, Some(100)); + check_account_balance("300"); + + // No fee + add_mint_block(6, 200, None, None); + check_account_balance("500"); + add_burn_block(7, 200, None, None); + check_account_balance("300"); + } } diff --git a/rs/sns/governance/token_valuation/src/tests.rs b/rs/sns/governance/token_valuation/src/tests.rs index f6a7728a6363..5b4e5875cce4 100644 --- a/rs/sns/governance/token_valuation/src/tests.rs +++ b/rs/sns/governance/token_valuation/src/tests.rs @@ -155,6 +155,7 @@ async fn test_icps_per_sns_token_client() { }, created_at_time: Some(GENESIS_TIMESTAMP_NANOSECONDS), memo: None, + fee: None, }), timestamp: GENESIS_TIMESTAMP_NANOSECONDS, kind: "mint".to_string(),