diff --git a/packages/icrc-ledger-types/src/icrc122/schema.rs b/packages/icrc-ledger-types/src/icrc122/schema.rs index 2f57972587ea..2b8b87fe5469 100644 --- a/packages/icrc-ledger-types/src/icrc122/schema.rs +++ b/packages/icrc-ledger-types/src/icrc122/schema.rs @@ -2,7 +2,7 @@ use std::borrow::Cow; use crate::icrc::generic_value_predicate::{ ItemRequirement, ValuePredicate, ValuePredicateFailures, and, is, is_blob, is_equal_to, is_map, - is_more_or_equal_to, is_nat, is_principal, is_text, item, len, or, + is_more_or_equal_to, is_principal, is_text, item, len, or, }; use crate::icrc::{generic_value::Value, generic_value_predicate::is_account}; @@ -26,17 +26,19 @@ fn block_validator( strict: bool, ) -> ValuePredicate { use ItemRequirement::*; - let caller_mthd_req = if strict { Required } else { Optional }; + let strict_req = if strict { Required } else { Optional }; let is_timestamp = is_more_or_equal_to(0); let is_parent_hash = and(vec![is_blob(), len(is_equal_to(32))]); + let is_created_at_time = is_more_or_equal_to(0); let is_transaction = and(vec![ is_map(), - item("mthd", caller_mthd_req.clone(), is_text()), + item("mthd", strict_req.clone(), is_text()), item(account_field, Required, is_account()), - item("amt", Required, is_nat()), - item("caller", caller_mthd_req, is_principal()), + item("amt", Required, is_more_or_equal_to(1)), + item("caller", strict_req.clone(), is_principal()), item("reason", Optional, is_text()), + item("ts", strict_req, is_created_at_time), ]); and(vec![ is_map(), @@ -142,6 +144,7 @@ mod tests { ("to", account_value()), ("amt", Value::Nat(1000_u64.into())), ("caller", principal_blob()), + ("ts", Value::Nat(999_000_000_u64.into())), ]), ), ]) @@ -159,6 +162,7 @@ mod tests { ("from", account_value()), ("amt", Value::Nat(1000_u64.into())), ("caller", principal_blob()), + ("ts", Value::Nat(999_000_000_u64.into())), ]), ), ]) diff --git a/packages/icrc-ledger-types/src/icrc3/transactions.rs b/packages/icrc-ledger-types/src/icrc3/transactions.rs index 4cb1e447b738..34a98e067937 100644 --- a/packages/icrc-ledger-types/src/icrc3/transactions.rs +++ b/packages/icrc-ledger-types/src/icrc3/transactions.rs @@ -20,6 +20,8 @@ pub const TRANSACTION_BURN: &str = "burn"; pub const TRANSACTION_MINT: &str = "mint"; pub const TRANSACTION_TRANSFER: &str = "transfer"; pub const TRANSACTION_FEE_COLLECTOR: &str = "107feecol"; +pub const TRANSACTION_AUTHORIZED_MINT: &str = "122mint"; +pub const TRANSACTION_AUTHORIZED_BURN: &str = "122burn"; pub type GenericTransaction = Value; @@ -73,6 +75,26 @@ pub struct FeeCollector { pub mthd: Option, } +#[derive(CandidType, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct AuthorizedMint { + pub amount: Nat, + pub to: Account, + pub created_at_time: Option, + pub caller: Option, + pub mthd: Option, + pub reason: Option, +} + +#[derive(CandidType, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct AuthorizedBurn { + pub amount: Nat, + pub from: Account, + pub created_at_time: Option, + pub caller: Option, + pub mthd: Option, + pub reason: Option, +} + // Representation of a Transaction which supports the Icrc1 Standard functionalities #[derive(CandidType, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct Transaction { @@ -82,6 +104,8 @@ pub struct Transaction { pub transfer: Option, pub approve: Option, pub fee_collector: Option, + pub authorized_mint: Option, + pub authorized_burn: Option, pub timestamp: u64, } @@ -95,6 +119,8 @@ impl Transaction { transfer: None, approve: None, fee_collector: None, + authorized_mint: None, + authorized_burn: None, } } @@ -107,6 +133,8 @@ impl Transaction { transfer: None, approve: None, fee_collector: None, + authorized_mint: None, + authorized_burn: None, } } @@ -119,6 +147,8 @@ impl Transaction { transfer: Some(transfer), approve: None, fee_collector: None, + authorized_mint: None, + authorized_burn: None, } } @@ -131,6 +161,8 @@ impl Transaction { transfer: None, approve: Some(approve), fee_collector: None, + authorized_mint: None, + authorized_burn: None, } } @@ -143,6 +175,36 @@ impl Transaction { transfer: None, approve: None, fee_collector: Some(fee_collector), + authorized_mint: None, + authorized_burn: None, + } + } + + pub fn authorized_mint(authorized_mint: AuthorizedMint, timestamp: u64) -> Self { + Self { + kind: TRANSACTION_AUTHORIZED_MINT.into(), + timestamp, + mint: None, + burn: None, + transfer: None, + approve: None, + fee_collector: None, + authorized_mint: Some(authorized_mint), + authorized_burn: None, + } + } + + pub fn authorized_burn(authorized_burn: AuthorizedBurn, timestamp: u64) -> Self { + Self { + kind: TRANSACTION_AUTHORIZED_BURN.into(), + timestamp, + mint: None, + burn: None, + transfer: None, + approve: None, + fee_collector: None, + authorized_mint: None, + authorized_burn: Some(authorized_burn), } } } diff --git a/rs/ledger_suite/icrc1/archive/archive.did b/rs/ledger_suite/icrc1/archive/archive.did index ed4416e2fd72..89ccb3dea5bf 100644 --- a/rs/ledger_suite/icrc1/archive/archive.did +++ b/rs/ledger_suite/icrc1/archive/archive.did @@ -7,10 +7,30 @@ type Transaction = record { mint : opt Mint; approve : opt Approve; fee_collector : opt FeeCollector; + authorized_mint : opt AuthorizedMint; + authorized_burn : opt AuthorizedBurn; timestamp : nat64; transfer : opt Transfer }; +type AuthorizedMint = record { + to : Account; + amount : nat; + created_at_time : opt nat64; + caller : opt principal; + mthd : opt text; + reason : opt text +}; + +type AuthorizedBurn = record { + from : Account; + amount : nat; + created_at_time : opt nat64; + caller : opt principal; + mthd : opt text; + reason : opt text +}; + type Approve = record { fee : opt nat; from : Account; diff --git a/rs/ledger_suite/icrc1/index-ng/index-ng.did b/rs/ledger_suite/icrc1/index-ng/index-ng.did index 52defc668704..ff784bd31e60 100644 --- a/rs/ledger_suite/icrc1/index-ng/index-ng.did +++ b/rs/ledger_suite/icrc1/index-ng/index-ng.did @@ -73,10 +73,30 @@ type Transaction = record { mint : opt Mint; approve : opt Approve; fee_collector : opt FeeCollector; + authorized_mint : opt AuthorizedMint; + authorized_burn : opt AuthorizedBurn; timestamp : nat64; transfer : opt Transfer }; +type AuthorizedMint = record { + to : Account; + amount : nat; + created_at_time : opt nat64; + caller : opt principal; + mthd : opt text; + reason : opt text +}; + +type AuthorizedBurn = record { + from : Account; + amount : nat; + created_at_time : opt nat64; + caller : opt principal; + mthd : opt text; + reason : opt text +}; + type FeeCollector = record { caller : opt principal; fee_collector : opt Account; diff --git a/rs/ledger_suite/icrc1/index-ng/src/main.rs b/rs/ledger_suite/icrc1/index-ng/src/main.rs index 00a32a261899..f0b763a70610 100644 --- a/rs/ledger_suite/icrc1/index-ng/src/main.rs +++ b/rs/ledger_suite/icrc1/index-ng/src/main.rs @@ -1113,8 +1113,11 @@ fn process_balance_changes(block_index: BlockIndex64, block: &Block) { } => { // Does not affect the balance } - Operation::AuthorizedMint { .. } | Operation::AuthorizedBurn { .. } => { - panic!("AuthorizedMint/AuthorizedBurn not yet supported in index-ng") + Operation::AuthorizedMint { to, amount, .. } => { + credit(block_index, to, amount); + } + Operation::AuthorizedBurn { from, amount, .. } => { + debit(block_index, from, amount); } }, ); @@ -1159,9 +1162,8 @@ fn get_accounts(block: &Block) -> Vec { } Operation::Approve { from, .. } => vec![from], Operation::FeeCollector { .. } => vec![], - Operation::AuthorizedMint { .. } | Operation::AuthorizedBurn { .. } => { - panic!("AuthorizedMint/AuthorizedBurn not yet supported in index-ng") - } + Operation::AuthorizedMint { to, .. } => vec![to], + Operation::AuthorizedBurn { from, .. } => vec![from], } } diff --git a/rs/ledger_suite/icrc1/index-ng/tests/tests.rs b/rs/ledger_suite/icrc1/index-ng/tests/tests.rs index 7c93146282c2..83be8b6128f6 100644 --- a/rs/ledger_suite/icrc1/index-ng/tests/tests.rs +++ b/rs/ledger_suite/icrc1/index-ng/tests/tests.rs @@ -969,6 +969,8 @@ fn test_get_account_transactions_pagination() { approve: None, timestamp: 0, fee_collector: None, + authorized_mint: None, + authorized_burn: None, }, transaction, ); @@ -1571,6 +1573,99 @@ fn test_block_with_no_btype_and_no_mthd() { ); } +#[test] +fn test_authorized_mint_and_burn_indexing() { + 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)); + + let account_1 = account(1, 0); + let account_2 = account(2, 0); + let caller = PrincipalId::new_user_test_id(99); + + // Block 0: regular mint to give account_1 initial balance + let block0 = BlockBuilder::new(0, 1000) + .mint(account_1, Tokens::from(10_000_000_u64)) + .build(); + assert_eq!( + Nat::from(0u64), + add_block(env, ledger_id, &block0).expect("failed to add block 0") + ); + + // Block 1: authorized mint to account_2 (ICRC-152 style, with caller/mthd/ts) + let block1 = BlockBuilder::new(1, 2000) + .authorized_mint(account_2, Tokens::from(5_000_000_u64)) + .with_caller(caller.0) + .with_mthd("152mint".to_string()) + .with_created_at_time(1500) + .build(); + assert_eq!( + Nat::from(1u64), + add_block(env, ledger_id, &block1).expect("failed to add block 1") + ); + + // Block 2: authorized burn from account_1 + let block2 = BlockBuilder::new(2, 3000) + .authorized_burn(account_1, Tokens::from(3_000_000_u64)) + .with_caller(caller.0) + .with_mthd("152burn".to_string()) + .with_created_at_time(2500) + .with_reason("compliance".to_string()) + .build(); + assert_eq!( + Nat::from(2u64), + add_block(env, ledger_id, &block2).expect("failed to add block 2") + ); + + wait_until_sync_is_completed(env, index_id, ledger_id); + + // Verify balances + // account_1: 10_000_000 (mint) - 3_000_000 (authorized burn) = 7_000_000 + assert_eq!(icrc1_balance_of(env, index_id, account_1), 7_000_000); + // account_2: 5_000_000 (authorized mint) + assert_eq!(icrc1_balance_of(env, index_id, account_2), 5_000_000); + + // Verify account_2 transactions (should have the authorized mint) + let txs = get_account_transactions(env, index_id, account_2, None, u64::MAX); + assert_eq!(txs.transactions.len(), 1); + assert_eq!(txs.transactions[0].id, Nat::from(1u64)); + let tx = &txs.transactions[0].transaction; + assert_eq!(tx.kind, "122mint"); + assert!(tx.authorized_mint.is_some()); + + // Verify account_1 transactions (should have the mint and the authorized burn) + let txs = get_account_transactions(env, index_id, account_1, None, u64::MAX); + assert_eq!(txs.transactions.len(), 2); + // Transactions are in descending order + assert_eq!(txs.transactions[0].id, Nat::from(2u64)); + assert_eq!(txs.transactions[0].transaction.kind, "122burn"); + assert!(txs.transactions[0].transaction.authorized_burn.is_some()); + assert_eq!(txs.transactions[1].id, Nat::from(0u64)); + assert_eq!(txs.transactions[1].transaction.kind, "mint"); +} + +#[test] +fn test_authorized_mint_minimal_icrc122_block() { + // Test a minimal ICRC-122 block (no caller, no mthd — permissive schema) + 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)); + + let account_1 = account(1, 0); + + let block0 = BlockBuilder::new(0, 1000) + .authorized_mint(account_1, Tokens::from(1_000_000_u64)) + .build(); + assert_eq!( + Nat::from(0u64), + add_block(env, ledger_id, &block0).expect("failed to add block 0") + ); + + wait_until_sync_is_completed(env, index_id, ledger_id); + + assert_eq!(icrc1_balance_of(env, index_id, account_1), 1_000_000); +} + #[test] fn test_index_ledger_coherence() { let mut runner = TestRunner::new(TestRunnerConfig::with_cases(1)); diff --git a/rs/ledger_suite/icrc1/ledger/ledger.did b/rs/ledger_suite/icrc1/ledger/ledger.did index 7cb5f7119cbe..578eb88879cc 100644 --- a/rs/ledger_suite/icrc1/ledger/ledger.did +++ b/rs/ledger_suite/icrc1/ledger/ledger.did @@ -225,10 +225,30 @@ type Transaction = record { mint : opt Mint; approve : opt Approve; fee_collector : opt FeeCollector; + authorized_mint : opt AuthorizedMint; + authorized_burn : opt AuthorizedBurn; timestamp : Timestamp; transfer : opt Transfer }; +type AuthorizedMint = record { + to : Account; + amount : nat; + created_at_time : opt Timestamp; + caller : opt principal; + mthd : opt text; + reason : opt text +}; + +type AuthorizedBurn = record { + from : Account; + amount : nat; + created_at_time : opt Timestamp; + caller : opt principal; + mthd : opt text; + reason : opt text +}; + type FeeCollector = record { caller : opt principal; fee_collector : opt Account; diff --git a/rs/ledger_suite/icrc1/ledger/src/tests.rs b/rs/ledger_suite/icrc1/ledger/src/tests.rs index 0386a5b2ce83..0919f6808f04 100644 --- a/rs/ledger_suite/icrc1/ledger/src/tests.rs +++ b/rs/ledger_suite/icrc1/ledger/src/tests.rs @@ -858,3 +858,76 @@ mod metadata_validation_tests { } } } + +#[test] +fn test_authorized_mint_credits_account() { + let now = ts(12345678); + let mut ctx = Ledger::from_init_args(DummyLogger, default_init_args(), now); + + let to = test_account_id(1); + let caller = PrincipalId::new_user_test_id(99).0; + + let tr = Transaction { + operation: Operation::AuthorizedMint { + to, + amount: tokens(50_000), + caller: Some(caller), + mthd: Some("152mint".to_string()), + reason: None, + }, + created_at_time: None, + memo: None, + }; + tr.apply(&mut ctx, now, Tokens::ZERO).unwrap(); + + assert_eq!(ctx.balances().account_balance(&to), tokens(50_000)); +} + +#[test] +fn test_authorized_burn_debits_account() { + let now = ts(12345678); + let mut ctx = Ledger::from_init_args(DummyLogger, default_init_args(), now); + + let from = test_account_id(1); + ctx.balances_mut().mint(&from, tokens(100_000)).unwrap(); + + let tr = Transaction { + operation: Operation::AuthorizedBurn { + from, + amount: tokens(30_000), + caller: Some(PrincipalId::new_user_test_id(99).0), + mthd: Some("152burn".to_string()), + reason: Some("compliance".to_string()), + }, + created_at_time: None, + memo: None, + }; + tr.apply(&mut ctx, now, Tokens::ZERO).unwrap(); + + assert_eq!(ctx.balances().account_balance(&from), tokens(70_000)); +} + +#[test] +fn test_authorized_burn_insufficient_balance_fails() { + let now = ts(12345678); + let mut ctx = Ledger::from_init_args(DummyLogger, default_init_args(), now); + + let from = test_account_id(1); + ctx.balances_mut().mint(&from, tokens(10_000)).unwrap(); + + let tr = Transaction { + operation: Operation::AuthorizedBurn { + from, + amount: tokens(50_000), + caller: Some(PrincipalId::new_user_test_id(99).0), + mthd: Some("152burn".to_string()), + reason: None, + }, + created_at_time: None, + memo: None, + }; + assert!(tr.apply(&mut ctx, now, Tokens::ZERO).is_err()); + + // Balance unchanged + assert_eq!(ctx.balances().account_balance(&from), tokens(10_000)); +} diff --git a/rs/ledger_suite/icrc1/src/endpoints.rs b/rs/ledger_suite/icrc1/src/endpoints.rs index 3a953bd01b5e..5efea769b263 100644 --- a/rs/ledger_suite/icrc1/src/endpoints.rs +++ b/rs/ledger_suite/icrc1/src/endpoints.rs @@ -7,7 +7,8 @@ use icrc_ledger_types::icrc1::transfer::TransferError; use icrc_ledger_types::icrc2::approve::ApproveError; use icrc_ledger_types::icrc2::transfer_from::TransferFromError; use icrc_ledger_types::icrc3::transactions::{ - Approve, Burn, FeeCollector, Mint, TRANSACTION_APPROVE, TRANSACTION_BURN, + Approve, AuthorizedBurn, AuthorizedMint, Burn, FeeCollector, Mint, TRANSACTION_APPROVE, + TRANSACTION_AUTHORIZED_BURN, TRANSACTION_AUTHORIZED_MINT, TRANSACTION_BURN, TRANSACTION_FEE_COLLECTOR, TRANSACTION_MINT, TRANSACTION_TRANSFER, Transaction, Transfer, }; use serde::Deserialize; @@ -163,6 +164,8 @@ impl From> for Transaction { transfer: None, approve: None, fee_collector: None, + authorized_mint: None, + authorized_burn: None, timestamp: b.timestamp, }; let created_at_time = b.transaction.created_at_time; @@ -248,8 +251,39 @@ impl From> for Transaction { mthd, }); } - Operation::AuthorizedMint { .. } | Operation::AuthorizedBurn { .. } => { - panic!("AuthorizedMint/AuthorizedBurn endpoint conversion not yet implemented") + Operation::AuthorizedMint { + to, + amount, + caller, + mthd, + reason, + } => { + tx.kind = TRANSACTION_AUTHORIZED_MINT.to_string(); + tx.authorized_mint = Some(AuthorizedMint { + to, + amount: amount.into(), + created_at_time, + caller, + mthd, + reason, + }); + } + Operation::AuthorizedBurn { + from, + amount, + caller, + mthd, + reason, + } => { + tx.kind = TRANSACTION_AUTHORIZED_BURN.to_string(); + tx.authorized_burn = Some(AuthorizedBurn { + from, + amount: amount.into(), + created_at_time, + caller, + mthd, + reason, + }); } } diff --git a/rs/ledger_suite/icrc1/src/lib.rs b/rs/ledger_suite/icrc1/src/lib.rs index 7180e0c57391..1e3cc011f954 100644 --- a/rs/ledger_suite/icrc1/src/lib.rs +++ b/rs/ledger_suite/icrc1/src/lib.rs @@ -540,8 +540,11 @@ impl LedgerTransaction for Transaction { return Err(e); } } - Operation::AuthorizedMint { .. } | Operation::AuthorizedBurn { .. } => { - panic!("AuthorizedMint/AuthorizedBurn not yet implemented") + Operation::AuthorizedMint { to, amount, .. } => { + context.balances_mut().mint(to, amount.clone())?; + } + Operation::AuthorizedBurn { from, amount, .. } => { + context.balances_mut().burn(from, amount.clone())?; } Operation::FeeCollector { .. } => { panic!("FeeCollector107 not implemented") diff --git a/rs/ledger_suite/icrc1/test_utils/src/icrc3.rs b/rs/ledger_suite/icrc1/test_utils/src/icrc3.rs index f29271e46f40..ec78acaafe4c 100644 --- a/rs/ledger_suite/icrc1/test_utils/src/icrc3.rs +++ b/rs/ledger_suite/icrc1/test_utils/src/icrc3.rs @@ -132,6 +132,32 @@ impl BlockBuilder { } } + /// Create an authorized mint operation (ICRC-122) + pub fn authorized_mint(self, to: Account, amount: Tokens) -> AuthorizedMintBuilder { + AuthorizedMintBuilder { + builder: self, + to, + amount, + caller: None, + mthd: None, + reason: None, + created_at_time: None, + } + } + + /// Create an authorized burn operation (ICRC-122) + pub fn authorized_burn(self, from: Account, amount: Tokens) -> AuthorizedBurnBuilder { + AuthorizedBurnBuilder { + builder: self, + from, + amount, + caller: None, + mthd: None, + reason: None, + created_at_time: None, + } + } + /// Create a fee collector block pub fn fee_collector( self, @@ -349,6 +375,112 @@ impl ApproveBuilder { } } +/// Builder for authorized mint operations (ICRC-122) +pub struct AuthorizedMintBuilder { + builder: BlockBuilder, + to: Account, + amount: Tokens, + caller: Option, + mthd: Option, + reason: Option, + created_at_time: Option, +} + +impl AuthorizedMintBuilder { + pub fn with_caller(mut self, caller: Principal) -> Self { + self.caller = Some(caller); + self + } + + pub fn with_mthd(mut self, mthd: String) -> Self { + self.mthd = Some(mthd); + self + } + + pub fn with_reason(mut self, reason: String) -> Self { + self.reason = Some(reason); + self + } + + pub fn with_created_at_time(mut self, ts: u64) -> Self { + self.created_at_time = Some(ts); + self + } + + pub fn build(mut self) -> ICRC3Value { + self.builder.btype = Some("122mint".to_string()); + let mut tx_fields = BTreeMap::new(); + tx_fields.insert("to".to_string(), account_to_icrc3_value(&self.to)); + tx_fields.insert("amt".to_string(), ICRC3Value::Nat(self.amount.into())); + if let Some(caller) = &self.caller { + tx_fields.insert("caller".to_string(), ICRC3Value::from(Value::from(*caller))); + } + if let Some(mthd) = self.mthd { + tx_fields.insert("mthd".to_string(), ICRC3Value::Text(mthd)); + } + if let Some(reason) = self.reason { + tx_fields.insert("reason".to_string(), ICRC3Value::Text(reason)); + } + if let Some(ts) = self.created_at_time { + tx_fields.insert("ts".to_string(), ICRC3Value::Nat(Nat::from(ts))); + } + self.builder.build_with_operation(None, tx_fields) + } +} + +/// Builder for authorized burn operations (ICRC-122) +pub struct AuthorizedBurnBuilder { + builder: BlockBuilder, + from: Account, + amount: Tokens, + caller: Option, + mthd: Option, + reason: Option, + created_at_time: Option, +} + +impl AuthorizedBurnBuilder { + pub fn with_caller(mut self, caller: Principal) -> Self { + self.caller = Some(caller); + self + } + + pub fn with_mthd(mut self, mthd: String) -> Self { + self.mthd = Some(mthd); + self + } + + pub fn with_reason(mut self, reason: String) -> Self { + self.reason = Some(reason); + self + } + + pub fn with_created_at_time(mut self, ts: u64) -> Self { + self.created_at_time = Some(ts); + self + } + + pub fn build(mut self) -> ICRC3Value { + self.builder.btype = Some("122burn".to_string()); + let mut tx_fields = BTreeMap::new(); + tx_fields.insert("from".to_string(), account_to_icrc3_value(&self.from)); + tx_fields.insert("amt".to_string(), ICRC3Value::Nat(self.amount.into())); + if let Some(caller) = &self.caller { + tx_fields.insert("caller".to_string(), ICRC3Value::from(Value::from(*caller))); + } + if let Some(mthd) = self.mthd { + tx_fields.insert("mthd".to_string(), ICRC3Value::Text(mthd)); + } + if let Some(reason) = self.reason { + tx_fields.insert("reason".to_string(), ICRC3Value::Text(reason)); + } + if let Some(ts) = self.created_at_time { + tx_fields.insert("ts".to_string(), ICRC3Value::Nat(Nat::from(ts))); + } + self.builder.build_with_operation(None, tx_fields) + } +} + /// Builder for fee collector operations pub struct FeeCollectorBuilder { builder: BlockBuilder, diff --git a/rs/ledger_suite/icrc1/test_utils/src/lib.rs b/rs/ledger_suite/icrc1/test_utils/src/lib.rs index 9ec7290879e9..68dec9c4e18c 100644 --- a/rs/ledger_suite/icrc1/test_utils/src/lib.rs +++ b/rs/ledger_suite/icrc1/test_utils/src/lib.rs @@ -16,6 +16,7 @@ use icrc_ledger_types::icrc1::transfer::{Memo, TransferArg}; use icrc_ledger_types::icrc2::approve::ApproveArgs; use icrc_ledger_types::icrc2::transfer_from::TransferFromArgs; use icrc_ledger_types::icrc107::schema::BTYPE_107; +use icrc_ledger_types::icrc122::schema::{BTYPE_122_BURN, BTYPE_122_MINT}; use num_traits::cast::ToPrimitive; use proptest::prelude::*; use proptest::sample::select; @@ -132,6 +133,7 @@ fn operation_strategy( fee, }); let approve_amount = amount.clone(); + let approve_expected_allowance = amount.clone(); let approve_strategy = ( account_strategy(), account_strategy(), @@ -148,7 +150,7 @@ fn operation_strategy( from, spender, amount: approve_amount.clone(), - expected_allowance: Some(amount.clone()), + expected_allowance: Some(approve_expected_allowance.clone()), expires_at, fee, }); @@ -170,12 +172,52 @@ fn operation_strategy( }, ); + // TODO: AuthorizedMint/AuthorizedBurn strategies are commented out until + // Rosetta support is implemented (PR 4). Rosetta proptests use blocks_strategy + // and will panic on these variants. Re-enable after Rosetta handles them. + /* + let authorized_mint_amount = amount.clone(); + let authorized_mint_strategy = ( + account_strategy(), + prop::option::of(principal_strategy()), + prop::option::of(Just("152mint".to_string())), + prop::option::of("[a-z]{3,10}".prop_map(|s| s)), + ) + .prop_map( + move |(to, caller, mthd, reason)| Operation::AuthorizedMint { + to, + amount: authorized_mint_amount.clone(), + caller, + mthd, + reason, + }, + ); + + let authorized_burn_strategy = ( + account_strategy(), + prop::option::of(principal_strategy()), + prop::option::of(Just("152burn".to_string())), + prop::option::of("[a-z]{3,10}".prop_map(|s| s)), + ) + .prop_map( + move |(from, caller, mthd, reason)| Operation::AuthorizedBurn { + from, + amount: amount.clone(), + caller, + mthd, + reason, + }, + ); + */ + prop_oneof![ mint_strategy, burn_strategy, transfer_strategy, approve_strategy, fee_collector_strategy, + // authorized_mint_strategy, + // authorized_burn_strategy, ] }) } @@ -265,6 +307,8 @@ pub fn blocks_strategy( }; let btype = match transaction.operation { Operation::FeeCollector { .. } => Some(BTYPE_107.to_string()), + Operation::AuthorizedMint { .. } => Some(BTYPE_122_MINT.to_string()), + Operation::AuthorizedBurn { .. } => Some(BTYPE_122_BURN.to_string()), _ => None, }; @@ -627,8 +671,11 @@ impl TransactionsAndBalances { Operation::FeeCollector { .. } => { panic!("FeeCollector107 not implemented") } - Operation::AuthorizedMint { .. } | Operation::AuthorizedBurn { .. } => { - panic!("AuthorizedMint/AuthorizedBurn not yet implemented in test_utils") + Operation::AuthorizedMint { to, amount, .. } => { + self.credit(to, amount.get_e8s()); + } + Operation::AuthorizedBurn { from, amount, .. } => { + self.debit(from, amount.get_e8s()); } }; self.transactions.push(tx); @@ -660,8 +707,11 @@ impl TransactionsAndBalances { Operation::FeeCollector { .. } => { panic!("FeeCollector107 not implemented") } - Operation::AuthorizedMint { .. } | Operation::AuthorizedBurn { .. } => { - panic!("AuthorizedMint/AuthorizedBurn not yet implemented in test_utils") + Operation::AuthorizedMint { to, .. } => { + self.check_and_update_account_validity(*to, default_fee); + } + Operation::AuthorizedBurn { from, .. } => { + self.check_and_update_account_validity(*from, default_fee); } } } diff --git a/rs/ledger_suite/icrc1/tests/tests.rs b/rs/ledger_suite/icrc1/tests/tests.rs index 8ea562a160fb..8061bb4effa3 100644 --- a/rs/ledger_suite/icrc1/tests/tests.rs +++ b/rs/ledger_suite/icrc1/tests/tests.rs @@ -328,3 +328,246 @@ mod block_encoding_stability { ); } } + +mod authorized_mint_burn_tests { + use super::*; + use candid::Principal; + use ic_icrc1::Operation; + use ic_ledger_core::block::BlockType; + use icrc_ledger_types::icrc1::account::Account; + use icrc_ledger_types::icrc3::transactions::{ + TRANSACTION_AUTHORIZED_BURN, TRANSACTION_AUTHORIZED_MINT, + }; + use icrc_ledger_types::icrc122::schema::{ + BTYPE_122_BURN, BTYPE_122_MINT, validate_152_burn, validate_152_mint, validate_burn, + validate_mint, + }; + + fn test_account(n: u64) -> Account { + Account { + owner: Principal::from_slice(&n.to_be_bytes()), + subaccount: None, + } + } + + fn make_authorized_mint_block( + caller: Option, + mthd: Option, + reason: Option, + created_at_time: Option, + ) -> Block { + let transaction = Transaction { + operation: Operation::AuthorizedMint { + to: test_account(1), + amount: U64::from(1_000_000u64), + caller, + mthd, + reason, + }, + created_at_time, + memo: None, + }; + Block::from_transaction( + None, + transaction, + ic_ledger_core::timestamp::TimeStamp::from_nanos_since_unix_epoch(1_000_000_000), + U64::from(0u64), + None, + ) + } + + fn make_authorized_burn_block( + caller: Option, + mthd: Option, + reason: Option, + created_at_time: Option, + ) -> Block { + let transaction = Transaction { + operation: Operation::AuthorizedBurn { + from: test_account(1), + amount: U64::from(500_000u64), + caller, + mthd, + reason, + }, + created_at_time, + memo: None, + }; + Block::from_transaction( + None, + transaction, + ic_ledger_core::timestamp::TimeStamp::from_nanos_since_unix_epoch(1_000_000_000), + U64::from(0u64), + None, + ) + } + + // --- CBOR round-trip tests --- + + #[test] + fn test_authorized_mint_cbor_round_trip() { + let block = make_authorized_mint_block( + Some(Principal::anonymous()), + Some("152mint".to_string()), + Some("test reason".to_string()), + Some(1_000_000_000), + ); + let encoded = block.clone().encode(); + let decoded = Block::::decode(encoded).unwrap(); + assert_eq!(block, decoded); + } + + #[test] + fn test_authorized_burn_cbor_round_trip() { + let block = make_authorized_burn_block( + Some(Principal::anonymous()), + Some("152burn".to_string()), + None, + Some(1_000_000_000), + ); + let encoded = block.clone().encode(); + let decoded = Block::::decode(encoded).unwrap(); + assert_eq!(block, decoded); + } + + #[test] + fn test_authorized_mint_btype_set_correctly() { + let block = make_authorized_mint_block(None, None, None, None); + assert_eq!(block.btype.as_deref(), Some(BTYPE_122_MINT)); + } + + #[test] + fn test_authorized_burn_btype_set_correctly() { + let block = make_authorized_burn_block(None, None, None, None); + assert_eq!(block.btype.as_deref(), Some(BTYPE_122_BURN)); + } + + #[test] + fn test_authorized_mint_generic_block_round_trip() { + let block = make_authorized_mint_block( + Some(Principal::anonymous()), + Some("152mint".to_string()), + None, + Some(1_000_000_000), + ); + let generic_block = encoded_block_to_generic_block(&block.clone().encode()); + let encoded_block = generic_block_to_encoded_block(generic_block.clone()).unwrap(); + let round_tripped = Block::::decode(encoded_block).unwrap(); + assert_eq!(block, round_tripped); + } + + #[test] + fn test_authorized_mint_hash_stability() { + let block = make_authorized_mint_block( + Some(Principal::anonymous()), + Some("152mint".to_string()), + None, + Some(1_000_000_000), + ); + let generic_block = encoded_block_to_generic_block(&block.clone().encode()); + assert_eq!( + generic_block.hash().to_vec(), + Block::::block_hash(&block.encode()) + .as_slice() + .to_vec() + ); + } + + // --- Candid conversion tests --- + + #[test] + fn test_authorized_mint_candid_conversion() { + let block = make_authorized_mint_block( + Some(Principal::anonymous()), + Some("152mint".to_string()), + Some("reason".to_string()), + Some(1_000_000_000), + ); + let tx: icrc_ledger_types::icrc3::transactions::Transaction = block.into(); + + assert_eq!(tx.kind, TRANSACTION_AUTHORIZED_MINT); + assert!(tx.authorized_mint.is_some()); + assert!(tx.authorized_burn.is_none()); + assert!(tx.mint.is_none()); + assert!(tx.burn.is_none()); + assert!(tx.transfer.is_none()); + assert!(tx.approve.is_none()); + assert!(tx.fee_collector.is_none()); + + let am = tx.authorized_mint.unwrap(); + assert_eq!(am.to, test_account(1)); + assert_eq!(am.amount, candid::Nat::from(1_000_000u64)); + assert_eq!(am.created_at_time, Some(1_000_000_000)); + assert_eq!(am.caller, Some(Principal::anonymous())); + assert_eq!(am.mthd, Some("152mint".to_string())); + assert_eq!(am.reason, Some("reason".to_string())); + } + + #[test] + fn test_authorized_burn_candid_conversion() { + let block = make_authorized_burn_block( + Some(Principal::anonymous()), + Some("152burn".to_string()), + None, + Some(1_000_000_000), + ); + let tx: icrc_ledger_types::icrc3::transactions::Transaction = block.into(); + + assert_eq!(tx.kind, TRANSACTION_AUTHORIZED_BURN); + assert!(tx.authorized_burn.is_some()); + assert!(tx.authorized_mint.is_none()); + assert!(tx.mint.is_none()); + + let ab = tx.authorized_burn.unwrap(); + assert_eq!(ab.from, test_account(1)); + assert_eq!(ab.amount, candid::Nat::from(500_000u64)); + assert_eq!(ab.created_at_time, Some(1_000_000_000)); + assert_eq!(ab.caller, Some(Principal::anonymous())); + assert_eq!(ab.mthd, Some("152burn".to_string())); + assert_eq!(ab.reason, None); + } + + // --- Schema validation tests --- + + #[test] + fn test_authorized_mint_block_passes_icrc152_validation() { + let block = make_authorized_mint_block( + Some(Principal::anonymous()), + Some("152mint".to_string()), + None, + Some(1_000_000_000), + ); + let generic_block = encoded_block_to_generic_block(&block.encode()); + assert!(validate_152_mint(&generic_block).is_ok()); + assert!(validate_mint(&generic_block).is_ok()); + } + + #[test] + fn test_authorized_burn_block_passes_icrc152_validation() { + let block = make_authorized_burn_block( + Some(Principal::anonymous()), + Some("152burn".to_string()), + None, + Some(1_000_000_000), + ); + let generic_block = encoded_block_to_generic_block(&block.encode()); + assert!(validate_152_burn(&generic_block).is_ok()); + assert!(validate_burn(&generic_block).is_ok()); + } + + #[test] + fn test_authorized_mint_without_caller_passes_icrc122_but_fails_icrc152() { + let block = make_authorized_mint_block(None, None, None, None); + let generic_block = encoded_block_to_generic_block(&block.encode()); + assert!(validate_mint(&generic_block).is_ok()); + assert!(validate_152_mint(&generic_block).is_err()); + } + + #[test] + fn test_authorized_burn_without_caller_passes_icrc122_but_fails_icrc152() { + let block = make_authorized_burn_block(None, None, None, None); + let generic_block = encoded_block_to_generic_block(&block.encode()); + assert!(validate_burn(&generic_block).is_ok()); + assert!(validate_152_burn(&generic_block).is_err()); + } +} 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 af478027eded..7f8464add27a 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 @@ -541,8 +541,11 @@ where Operation::FeeCollector { .. } => { panic!("FeeCollector107 not implemented") } - Operation::AuthorizedMint { .. } | Operation::AuthorizedBurn { .. } => { - panic!("AuthorizedMint/AuthorizedBurn not yet implemented in in_memory_ledger") + Operation::AuthorizedMint { to, amount, .. } => { + self.process_mint(to, amount); + } + Operation::AuthorizedBurn { from, amount, .. } => { + self.process_burn(from, &None, amount, index); } } } diff --git a/rs/ledger_suite/test_utils/in_memory_ledger/src/tests.rs b/rs/ledger_suite/test_utils/in_memory_ledger/src/tests.rs index 6107ddce579f..0bcfe5dc5fac 100644 --- a/rs/ledger_suite/test_utils/in_memory_ledger/src/tests.rs +++ b/rs/ledger_suite/test_utils/in_memory_ledger/src/tests.rs @@ -569,6 +569,103 @@ fn should_increase_and_decrease_balance_with_transfer_from() { assert_eq!(Some(&Tokens::from(TRANSFER_AMOUNT)), actual_balance3); } +const AUTHORIZED_BURN_AMOUNT: u64 = 100_000_u64; + +#[test] +fn should_not_consume_allowance_on_authorized_burn() { + // AuthorizedBurn is a privileged operation that bypasses the transfer API, + // so it must not deduct from any existing allowance even if the caller + // would match an approved spender. + let now = TimeStamp::from_nanos_since_unix_epoch(TIMESTAMP_NOW); + let account_1 = account_from_u64(ACCOUNT_ID_1); + let spender = account_from_u64(ACCOUNT_ID_2); + + let ledger = LedgerBuilder::new() + // Fund the account + .with_mint(&account_1, &Tokens::from(MINT_AMOUNT)) + // Approve spender to spend from account_1 + .with_approve( + &account_1, + &spender, + &Tokens::from(APPROVE_AMOUNT), + &None, + &None, + &None, + now, + ) + // Authorized burn from account_1 (spender = None, as in AuthorizedBurn processing) + .with_burn(&account_1, &None, &Tokens::from(AUTHORIZED_BURN_AMOUNT)) + .build(); + + // Allowance must be fully intact + let allowance_key = ApprovalKey::from((&account_1, &spender)); + let actual_allowance = ledger.allowances.get(&allowance_key); + let expected_allowance = Allowance { + amount: Tokens::from(APPROVE_AMOUNT), + expires_at: None, + arrived_at: now, + }; + assert_eq!(actual_allowance, Some(&expected_allowance)); + + // Balance reflects the burn + let expected_balance = Tokens::from(MINT_AMOUNT) + .checked_sub(&Tokens::from(AUTHORIZED_BURN_AMOUNT)) + .unwrap(); + assert_eq!(ledger.balances.get(&account_1), Some(&expected_balance)); +} + +#[test] +fn should_consume_allowance_on_regular_burn_after_authorized_burn() { + // After an authorized burn, a subsequent regular burn via spender should + // still find the full allowance available. + let now = TimeStamp::from_nanos_since_unix_epoch(TIMESTAMP_NOW); + let account_1 = account_from_u64(ACCOUNT_ID_1); + let spender = account_from_u64(ACCOUNT_ID_2); + let regular_burn_amount: u64 = 100_000; + + let ledger = LedgerBuilder::new() + .with_mint(&account_1, &Tokens::from(MINT_AMOUNT)) + .with_approve( + &account_1, + &spender, + &Tokens::from(APPROVE_AMOUNT), + &None, + &None, + &None, + now, + ) + // Authorized burn (no spender, as the ledger processes AuthorizedBurn) + .with_burn(&account_1, &None, &Tokens::from(AUTHORIZED_BURN_AMOUNT)) + // Regular burn via spender — should consume from the allowance + .with_burn( + &account_1, + &Some(spender), + &Tokens::from(regular_burn_amount), + ) + .build(); + + // Allowance should be reduced by the regular burn only, not the authorized burn + let allowance_key = ApprovalKey::from((&account_1, &spender)); + let actual_allowance = ledger.allowances.get(&allowance_key); + let expected_remaining = Tokens::from(APPROVE_AMOUNT) + .checked_sub(&Tokens::from(regular_burn_amount)) + .unwrap(); + let expected_allowance = Allowance { + amount: expected_remaining, + expires_at: None, + arrived_at: now, + }; + assert_eq!(actual_allowance, Some(&expected_allowance)); + + // Balance reflects both burns + let expected_balance = Tokens::from(MINT_AMOUNT) + .checked_sub(&Tokens::from(AUTHORIZED_BURN_AMOUNT)) + .unwrap() + .checked_sub(&Tokens::from(regular_burn_amount)) + .unwrap(); + assert_eq!(ledger.balances.get(&account_1), Some(&expected_balance)); +} + fn account_from_u64(account_id: u64) -> AccountType { AccountType::from(Account { owner: PrincipalId::new_user_test_id(account_id).0, diff --git a/rs/nervous_system/initial_supply/src/tests.rs b/rs/nervous_system/initial_supply/src/tests.rs index 94274e15c173..d57ff9577296 100644 --- a/rs/nervous_system/initial_supply/src/tests.rs +++ b/rs/nervous_system/initial_supply/src/tests.rs @@ -77,6 +77,8 @@ async fn test_initial_supply() { approve: None, transfer: None, fee_collector: None, + authorized_mint: None, + authorized_burn: None, } }; diff --git a/rs/sns/governance/token_valuation/src/tests.rs b/rs/sns/governance/token_valuation/src/tests.rs index 09eb9eada405..30bf353c7304 100644 --- a/rs/sns/governance/token_valuation/src/tests.rs +++ b/rs/sns/governance/token_valuation/src/tests.rs @@ -164,6 +164,8 @@ async fn test_icps_per_sns_token_client() { approve: None, transfer: None, fee_collector: None, + authorized_mint: None, + authorized_burn: None, }, ],