From 72ca32213f36b1fdffa9454940b86e7716ed1765 Mon Sep 17 00:00:00 2001 From: Bogdan Warinschi Date: Wed, 1 Apr 2026 06:59:59 +0000 Subject: [PATCH 01/12] feat(ic-icrc1): add ICRC-122 AuthorizedMint/AuthorizedBurn candid types, index-ng, and .did support - Add AuthorizedMint/AuthorizedBurn Candid structs and Transaction fields - Replace panic stubs with real implementations in index-ng (balance changes and account extraction), endpoints (Block-to-Transaction conversion), test_utils, in_memory_ledger, and Transaction::apply - Update ledger.did, archive.did, and index-ng.did with new types Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/icrc3/transactions.rs | 64 +++++++++++++++++++ rs/ledger_suite/icrc1/archive/archive.did | 22 +++++++ rs/ledger_suite/icrc1/index-ng/index-ng.did | 22 +++++++ rs/ledger_suite/icrc1/index-ng/src/main.rs | 12 ++-- rs/ledger_suite/icrc1/index-ng/tests/tests.rs | 2 + rs/ledger_suite/icrc1/ledger/ledger.did | 22 +++++++ rs/ledger_suite/icrc1/src/endpoints.rs | 42 +++++++++++- rs/ledger_suite/icrc1/src/lib.rs | 7 +- rs/ledger_suite/icrc1/test_utils/src/lib.rs | 14 ++-- .../test_utils/in_memory_ledger/src/lib.rs | 7 +- 10 files changed, 198 insertions(+), 16 deletions(-) diff --git a/packages/icrc-ledger-types/src/icrc3/transactions.rs b/packages/icrc-ledger-types/src/icrc3/transactions.rs index 4cb1e447b738..7fba1a25465a 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,28 @@ pub struct FeeCollector { pub mthd: Option, } +#[derive(CandidType, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct AuthorizedMint { + pub amount: Nat, + pub to: Account, + pub memo: Option, + 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 memo: Option, + 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 +106,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 +121,8 @@ impl Transaction { transfer: None, approve: None, fee_collector: None, + authorized_mint: None, + authorized_burn: None, } } @@ -107,6 +135,8 @@ impl Transaction { transfer: None, approve: None, fee_collector: None, + authorized_mint: None, + authorized_burn: None, } } @@ -119,6 +149,8 @@ impl Transaction { transfer: Some(transfer), approve: None, fee_collector: None, + authorized_mint: None, + authorized_burn: None, } } @@ -131,6 +163,8 @@ impl Transaction { transfer: None, approve: Some(approve), fee_collector: None, + authorized_mint: None, + authorized_burn: None, } } @@ -143,6 +177,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..f71d66c727a4 100644 --- a/rs/ledger_suite/icrc1/archive/archive.did +++ b/rs/ledger_suite/icrc1/archive/archive.did @@ -7,10 +7,32 @@ 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; + memo : opt vec nat8; + created_at_time : opt nat64; + amount : nat; + caller : opt principal; + mthd : opt text; + reason : opt text +}; + +type AuthorizedBurn = record { + from : Account; + memo : opt vec nat8; + created_at_time : opt nat64; + amount : nat; + 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..72cdcf7a76f9 100644 --- a/rs/ledger_suite/icrc1/index-ng/index-ng.did +++ b/rs/ledger_suite/icrc1/index-ng/index-ng.did @@ -73,10 +73,32 @@ 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; + memo : opt vec nat8; + created_at_time : opt nat64; + amount : nat; + caller : opt principal; + mthd : opt text; + reason : opt text +}; + +type AuthorizedBurn = record { + from : Account; + memo : opt vec nat8; + created_at_time : opt nat64; + amount : nat; + 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..983cea50fce4 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, ); diff --git a/rs/ledger_suite/icrc1/ledger/ledger.did b/rs/ledger_suite/icrc1/ledger/ledger.did index 7cb5f7119cbe..851c55bad792 100644 --- a/rs/ledger_suite/icrc1/ledger/ledger.did +++ b/rs/ledger_suite/icrc1/ledger/ledger.did @@ -225,10 +225,32 @@ 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; + memo : opt blob; + created_at_time : opt Timestamp; + amount : nat; + caller : opt principal; + mthd : opt text; + reason : opt text +}; + +type AuthorizedBurn = record { + from : Account; + memo : opt blob; + created_at_time : opt Timestamp; + amount : nat; + 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/src/endpoints.rs b/rs/ledger_suite/icrc1/src/endpoints.rs index 3a953bd01b5e..f2137383def4 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,41 @@ 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, + memo, + 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, + memo, + 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/lib.rs b/rs/ledger_suite/icrc1/test_utils/src/lib.rs index 9ec7290879e9..7868b89a7699 100644 --- a/rs/ledger_suite/icrc1/test_utils/src/lib.rs +++ b/rs/ledger_suite/icrc1/test_utils/src/lib.rs @@ -627,8 +627,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 +663,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/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); } } } From cecbc6a10128a57511a8656c20c429c8fa4ddbab Mon Sep 17 00:00:00 2001 From: Bogdan Warinschi Date: Wed, 1 Apr 2026 08:08:44 +0000 Subject: [PATCH 02/12] fix(ic-icrc1): remove memo/created_at_time from AuthorizedMint/AuthorizedBurn Candid types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ICRC-122 block schema defines tx fields as {mthd, to/from, amt, caller, reason} — memo and created_at_time are not part of the standard. Remove them from the Candid structs, endpoint conversion, and .did files. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/icrc-ledger-types/src/icrc3/transactions.rs | 4 ---- rs/ledger_suite/icrc1/archive/archive.did | 4 ---- rs/ledger_suite/icrc1/index-ng/index-ng.did | 4 ---- rs/ledger_suite/icrc1/ledger/ledger.did | 4 ---- rs/ledger_suite/icrc1/src/endpoints.rs | 4 ---- 5 files changed, 20 deletions(-) diff --git a/packages/icrc-ledger-types/src/icrc3/transactions.rs b/packages/icrc-ledger-types/src/icrc3/transactions.rs index 7fba1a25465a..507d5e219d3d 100644 --- a/packages/icrc-ledger-types/src/icrc3/transactions.rs +++ b/packages/icrc-ledger-types/src/icrc3/transactions.rs @@ -79,8 +79,6 @@ pub struct FeeCollector { pub struct AuthorizedMint { pub amount: Nat, pub to: Account, - pub memo: Option, - pub created_at_time: Option, pub caller: Option, pub mthd: Option, pub reason: Option, @@ -90,8 +88,6 @@ pub struct AuthorizedMint { pub struct AuthorizedBurn { pub amount: Nat, pub from: Account, - pub memo: Option, - pub created_at_time: Option, pub caller: Option, pub mthd: Option, pub reason: Option, diff --git a/rs/ledger_suite/icrc1/archive/archive.did b/rs/ledger_suite/icrc1/archive/archive.did index f71d66c727a4..254bd205f54c 100644 --- a/rs/ledger_suite/icrc1/archive/archive.did +++ b/rs/ledger_suite/icrc1/archive/archive.did @@ -15,8 +15,6 @@ type Transaction = record { type AuthorizedMint = record { to : Account; - memo : opt vec nat8; - created_at_time : opt nat64; amount : nat; caller : opt principal; mthd : opt text; @@ -25,8 +23,6 @@ type AuthorizedMint = record { type AuthorizedBurn = record { from : Account; - memo : opt vec nat8; - created_at_time : opt nat64; amount : nat; caller : opt principal; mthd : opt text; diff --git a/rs/ledger_suite/icrc1/index-ng/index-ng.did b/rs/ledger_suite/icrc1/index-ng/index-ng.did index 72cdcf7a76f9..807c08ed54f2 100644 --- a/rs/ledger_suite/icrc1/index-ng/index-ng.did +++ b/rs/ledger_suite/icrc1/index-ng/index-ng.did @@ -81,8 +81,6 @@ type Transaction = record { type AuthorizedMint = record { to : Account; - memo : opt vec nat8; - created_at_time : opt nat64; amount : nat; caller : opt principal; mthd : opt text; @@ -91,8 +89,6 @@ type AuthorizedMint = record { type AuthorizedBurn = record { from : Account; - memo : opt vec nat8; - created_at_time : opt nat64; amount : nat; caller : opt principal; mthd : opt text; diff --git a/rs/ledger_suite/icrc1/ledger/ledger.did b/rs/ledger_suite/icrc1/ledger/ledger.did index 851c55bad792..2fe1baf24ea9 100644 --- a/rs/ledger_suite/icrc1/ledger/ledger.did +++ b/rs/ledger_suite/icrc1/ledger/ledger.did @@ -233,8 +233,6 @@ type Transaction = record { type AuthorizedMint = record { to : Account; - memo : opt blob; - created_at_time : opt Timestamp; amount : nat; caller : opt principal; mthd : opt text; @@ -243,8 +241,6 @@ type AuthorizedMint = record { type AuthorizedBurn = record { from : Account; - memo : opt blob; - created_at_time : opt Timestamp; amount : nat; caller : opt principal; mthd : opt text; diff --git a/rs/ledger_suite/icrc1/src/endpoints.rs b/rs/ledger_suite/icrc1/src/endpoints.rs index f2137383def4..68982685c5cf 100644 --- a/rs/ledger_suite/icrc1/src/endpoints.rs +++ b/rs/ledger_suite/icrc1/src/endpoints.rs @@ -262,8 +262,6 @@ impl From> for Transaction { tx.authorized_mint = Some(AuthorizedMint { to, amount: amount.into(), - created_at_time, - memo, caller, mthd, reason, @@ -280,8 +278,6 @@ impl From> for Transaction { tx.authorized_burn = Some(AuthorizedBurn { from, amount: amount.into(), - created_at_time, - memo, caller, mthd, reason, From c654057e9cf06c173ab30a5629e80039d2f4cb43 Mon Sep 17 00:00:00 2001 From: Bogdan Warinschi Date: Wed, 1 Apr 2026 09:11:41 +0000 Subject: [PATCH 03/12] test(ic-icrc1): add ICRC-122 AuthorizedMint/AuthorizedBurn tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CBOR round-trip: encode/decode preserves all fields and btype - Hash stability: generic block hash matches encoded block hash - Candid conversion: Block→Transaction produces correct kind and fields - Schema validation: blocks with caller/mthd pass ICRC-152 validators, blocks without them pass ICRC-122 but fail ICRC-152 - Transaction::apply: AuthorizedMint credits, AuthorizedBurn debits, insufficient balance fails - Proptest: extend operation_strategy to generate AuthorizedMint/AuthorizedBurn - Fix btype in blocks_strategy for AuthorizedMint/AuthorizedBurn - Fix schema: use is_more_or_equal_to(0) for amt to accept Nat64 from CBOR decoder (same approach as ts field) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../icrc-ledger-types/src/icrc122/schema.rs | 4 +- rs/ledger_suite/icrc1/ledger/src/tests.rs | 73 ++++++ rs/ledger_suite/icrc1/test_utils/src/lib.rs | 41 +++- rs/ledger_suite/icrc1/tests/tests.rs | 231 ++++++++++++++++++ 4 files changed, 346 insertions(+), 3 deletions(-) diff --git a/packages/icrc-ledger-types/src/icrc122/schema.rs b/packages/icrc-ledger-types/src/icrc122/schema.rs index 2f57972587ea..cb012dc095a3 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}; @@ -34,7 +34,7 @@ fn block_validator( is_map(), item("mthd", caller_mthd_req.clone(), is_text()), item(account_field, Required, is_account()), - item("amt", Required, is_nat()), + item("amt", Required, is_more_or_equal_to(0)), item("caller", caller_mthd_req, is_principal()), item("reason", Optional, is_text()), ]); 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/test_utils/src/lib.rs b/rs/ledger_suite/icrc1/test_utils/src/lib.rs index 7868b89a7699..9428c7f1cb69 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,47 @@ fn operation_strategy( }, ); + 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 +302,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, }; diff --git a/rs/ledger_suite/icrc1/tests/tests.rs b/rs/ledger_suite/icrc1/tests/tests.rs index 8ea562a160fb..ce4f5a2a8e4a 100644 --- a/rs/ledger_suite/icrc1/tests/tests.rs +++ b/rs/ledger_suite/icrc1/tests/tests.rs @@ -328,3 +328,234 @@ 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, + ) -> Block { + let transaction = Transaction { + operation: Operation::AuthorizedMint { + to: test_account(1), + amount: U64::from(1_000_000u64), + caller, + mthd, + reason, + }, + created_at_time: None, + 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, + ) -> Block { + let transaction = Transaction { + operation: Operation::AuthorizedBurn { + from: test_account(1), + amount: U64::from(500_000u64), + caller, + mthd, + reason, + }, + created_at_time: None, + 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()), + ); + 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, + ); + 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); + 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); + 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, + ); + 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, + ); + 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()), + ); + 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.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, + ); + 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.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, + ); + 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, + ); + 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); + 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); + 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()); + } +} From bf4d44045d21e0d2372ac12908316993cf958e70 Mon Sep 17 00:00:00 2001 From: Bogdan Warinschi Date: Wed, 1 Apr 2026 13:56:34 +0000 Subject: [PATCH 04/12] fix(ic-icrc1): add created_at_time to AuthorizedMint/AuthorizedBurn per ICRC-152 ICRC-152 canonical tx mapping includes created_at_time as a tx field (used for deduplication). Add it back to Candid structs, endpoint conversion, .did files, and schema validators. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/icrc-ledger-types/src/icrc122/schema.rs | 2 ++ packages/icrc-ledger-types/src/icrc3/transactions.rs | 2 ++ rs/ledger_suite/icrc1/archive/archive.did | 2 ++ rs/ledger_suite/icrc1/index-ng/index-ng.did | 2 ++ rs/ledger_suite/icrc1/ledger/ledger.did | 2 ++ rs/ledger_suite/icrc1/src/endpoints.rs | 2 ++ 6 files changed, 12 insertions(+) diff --git a/packages/icrc-ledger-types/src/icrc122/schema.rs b/packages/icrc-ledger-types/src/icrc122/schema.rs index cb012dc095a3..0fc9976674e4 100644 --- a/packages/icrc-ledger-types/src/icrc122/schema.rs +++ b/packages/icrc-ledger-types/src/icrc122/schema.rs @@ -30,6 +30,7 @@ fn block_validator( 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()), @@ -37,6 +38,7 @@ fn block_validator( item("amt", Required, is_more_or_equal_to(0)), item("caller", caller_mthd_req, is_principal()), item("reason", Optional, is_text()), + item("created_at_time", Optional, is_created_at_time), ]); and(vec![ is_map(), diff --git a/packages/icrc-ledger-types/src/icrc3/transactions.rs b/packages/icrc-ledger-types/src/icrc3/transactions.rs index 507d5e219d3d..34a98e067937 100644 --- a/packages/icrc-ledger-types/src/icrc3/transactions.rs +++ b/packages/icrc-ledger-types/src/icrc3/transactions.rs @@ -79,6 +79,7 @@ pub struct FeeCollector { pub struct AuthorizedMint { pub amount: Nat, pub to: Account, + pub created_at_time: Option, pub caller: Option, pub mthd: Option, pub reason: Option, @@ -88,6 +89,7 @@ pub struct AuthorizedMint { pub struct AuthorizedBurn { pub amount: Nat, pub from: Account, + pub created_at_time: Option, pub caller: Option, pub mthd: Option, pub reason: Option, diff --git a/rs/ledger_suite/icrc1/archive/archive.did b/rs/ledger_suite/icrc1/archive/archive.did index 254bd205f54c..89ccb3dea5bf 100644 --- a/rs/ledger_suite/icrc1/archive/archive.did +++ b/rs/ledger_suite/icrc1/archive/archive.did @@ -16,6 +16,7 @@ type Transaction = record { type AuthorizedMint = record { to : Account; amount : nat; + created_at_time : opt nat64; caller : opt principal; mthd : opt text; reason : opt text @@ -24,6 +25,7 @@ type AuthorizedMint = record { type AuthorizedBurn = record { from : Account; amount : nat; + created_at_time : opt nat64; caller : opt principal; mthd : opt text; reason : opt text diff --git a/rs/ledger_suite/icrc1/index-ng/index-ng.did b/rs/ledger_suite/icrc1/index-ng/index-ng.did index 807c08ed54f2..ff784bd31e60 100644 --- a/rs/ledger_suite/icrc1/index-ng/index-ng.did +++ b/rs/ledger_suite/icrc1/index-ng/index-ng.did @@ -82,6 +82,7 @@ type Transaction = record { type AuthorizedMint = record { to : Account; amount : nat; + created_at_time : opt nat64; caller : opt principal; mthd : opt text; reason : opt text @@ -90,6 +91,7 @@ type AuthorizedMint = record { type AuthorizedBurn = record { from : Account; amount : nat; + created_at_time : opt nat64; caller : opt principal; mthd : opt text; reason : opt text diff --git a/rs/ledger_suite/icrc1/ledger/ledger.did b/rs/ledger_suite/icrc1/ledger/ledger.did index 2fe1baf24ea9..578eb88879cc 100644 --- a/rs/ledger_suite/icrc1/ledger/ledger.did +++ b/rs/ledger_suite/icrc1/ledger/ledger.did @@ -234,6 +234,7 @@ type Transaction = record { type AuthorizedMint = record { to : Account; amount : nat; + created_at_time : opt Timestamp; caller : opt principal; mthd : opt text; reason : opt text @@ -242,6 +243,7 @@ type AuthorizedMint = record { type AuthorizedBurn = record { from : Account; amount : nat; + created_at_time : opt Timestamp; caller : opt principal; mthd : opt text; reason : opt text diff --git a/rs/ledger_suite/icrc1/src/endpoints.rs b/rs/ledger_suite/icrc1/src/endpoints.rs index 68982685c5cf..5efea769b263 100644 --- a/rs/ledger_suite/icrc1/src/endpoints.rs +++ b/rs/ledger_suite/icrc1/src/endpoints.rs @@ -262,6 +262,7 @@ impl From> for Transaction { tx.authorized_mint = Some(AuthorizedMint { to, amount: amount.into(), + created_at_time, caller, mthd, reason, @@ -278,6 +279,7 @@ impl From> for Transaction { tx.authorized_burn = Some(AuthorizedBurn { from, amount: amount.into(), + created_at_time, caller, mthd, reason, From e326c5f557f46eaed7ff973de3f65adffc4969a9 Mon Sep 17 00:00:00 2001 From: Bogdan Warinschi Date: Wed, 1 Apr 2026 15:35:29 +0000 Subject: [PATCH 05/12] fix(ic-icrc1): use tx.ts for created_at_time in schema and tests per CBOR encoding The FlattenedTransaction serializes created_at_time as "ts" in CBOR. Update schema validators to check for "ts" inside tx (matching what the encoder produces), make it required in strict (ICRC-152) mode, and fix test helpers to pass created_at_time for ICRC-152 blocks. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../icrc-ledger-types/src/icrc122/schema.rs | 10 ++++--- rs/ledger_suite/icrc1/tests/tests.rs | 26 ++++++++++++++----- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/packages/icrc-ledger-types/src/icrc122/schema.rs b/packages/icrc-ledger-types/src/icrc122/schema.rs index 0fc9976674e4..3d3c4ee5ba5d 100644 --- a/packages/icrc-ledger-types/src/icrc122/schema.rs +++ b/packages/icrc-ledger-types/src/icrc122/schema.rs @@ -26,19 +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_more_or_equal_to(0)), - item("caller", caller_mthd_req, is_principal()), + item("caller", strict_req.clone(), is_principal()), item("reason", Optional, is_text()), - item("created_at_time", Optional, is_created_at_time), + item("ts", strict_req, is_created_at_time), ]); and(vec![ is_map(), @@ -144,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())), ]), ), ]) @@ -161,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/rs/ledger_suite/icrc1/tests/tests.rs b/rs/ledger_suite/icrc1/tests/tests.rs index ce4f5a2a8e4a..b471ccbae259 100644 --- a/rs/ledger_suite/icrc1/tests/tests.rs +++ b/rs/ledger_suite/icrc1/tests/tests.rs @@ -354,6 +354,7 @@ mod authorized_mint_burn_tests { caller: Option, mthd: Option, reason: Option, + created_at_time: Option, ) -> Block { let transaction = Transaction { operation: Operation::AuthorizedMint { @@ -363,7 +364,7 @@ mod authorized_mint_burn_tests { mthd, reason, }, - created_at_time: None, + created_at_time, memo: None, }; Block::from_transaction( @@ -379,6 +380,7 @@ mod authorized_mint_burn_tests { caller: Option, mthd: Option, reason: Option, + created_at_time: Option, ) -> Block { let transaction = Transaction { operation: Operation::AuthorizedBurn { @@ -388,7 +390,7 @@ mod authorized_mint_burn_tests { mthd, reason, }, - created_at_time: None, + created_at_time, memo: None, }; Block::from_transaction( @@ -402,12 +404,15 @@ mod authorized_mint_burn_tests { // --- CBOR round-trip tests --- + // --- 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(); @@ -420,6 +425,7 @@ mod authorized_mint_burn_tests { Some(Principal::anonymous()), Some("152burn".to_string()), None, + Some(1_000_000_000), ); let encoded = block.clone().encode(); let decoded = Block::::decode(encoded).unwrap(); @@ -428,13 +434,13 @@ mod authorized_mint_burn_tests { #[test] fn test_authorized_mint_btype_set_correctly() { - let block = make_authorized_mint_block(None, None, None); + 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); + let block = make_authorized_burn_block(None, None, None, None); assert_eq!(block.btype.as_deref(), Some(BTYPE_122_BURN)); } @@ -444,6 +450,7 @@ mod authorized_mint_burn_tests { 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(); @@ -457,6 +464,7 @@ mod authorized_mint_burn_tests { 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!( @@ -475,6 +483,7 @@ mod authorized_mint_burn_tests { 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(); @@ -490,6 +499,7 @@ mod authorized_mint_burn_tests { 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())); @@ -501,6 +511,7 @@ mod authorized_mint_burn_tests { Some(Principal::anonymous()), Some("152burn".to_string()), None, + Some(1_000_000_000), ); let tx: icrc_ledger_types::icrc3::transactions::Transaction = block.into(); @@ -512,6 +523,7 @@ mod authorized_mint_burn_tests { 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); @@ -525,6 +537,7 @@ mod authorized_mint_burn_tests { 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()); @@ -537,6 +550,7 @@ mod authorized_mint_burn_tests { 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()); @@ -545,7 +559,7 @@ mod authorized_mint_burn_tests { #[test] fn test_authorized_mint_without_caller_passes_icrc122_but_fails_icrc152() { - let block = make_authorized_mint_block(None, None, None); + 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()); @@ -553,7 +567,7 @@ mod authorized_mint_burn_tests { #[test] fn test_authorized_burn_without_caller_passes_icrc122_but_fails_icrc152() { - let block = make_authorized_burn_block(None, None, None); + 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()); From aa4cfe1fd9e447227958a4316dcfbde4c7805116 Mon Sep 17 00:00:00 2001 From: Bogdan Warinschi Date: Wed, 1 Apr 2026 20:23:27 +0000 Subject: [PATCH 06/12] fix: add authorized_mint/authorized_burn fields to Transaction literals in sns and nervous_system tests Co-Authored-By: Claude Opus 4.6 (1M context) --- rs/nervous_system/initial_supply/src/tests.rs | 2 ++ rs/sns/governance/token_valuation/src/tests.rs | 2 ++ 2 files changed, 4 insertions(+) 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, }, ], From 2fc7c2bd927b263cf81da6a2210e4fd253c182f2 Mon Sep 17 00:00:00 2001 From: Bogdan Warinschi Date: Wed, 1 Apr 2026 20:37:11 +0000 Subject: [PATCH 07/12] fix: remove duplicate comment in tests Co-Authored-By: Claude Opus 4.6 (1M context) --- rs/ledger_suite/icrc1/tests/tests.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/rs/ledger_suite/icrc1/tests/tests.rs b/rs/ledger_suite/icrc1/tests/tests.rs index b471ccbae259..8061bb4effa3 100644 --- a/rs/ledger_suite/icrc1/tests/tests.rs +++ b/rs/ledger_suite/icrc1/tests/tests.rs @@ -404,8 +404,6 @@ mod authorized_mint_burn_tests { // --- CBOR round-trip tests --- - // --- CBOR round-trip tests --- - #[test] fn test_authorized_mint_cbor_round_trip() { let block = make_authorized_mint_block( From c20cd4e4a8c7bc7ac074a1fe58f70eb1afbc4d14 Mon Sep 17 00:00:00 2001 From: Bogdan Warinschi Date: Wed, 1 Apr 2026 21:19:25 +0000 Subject: [PATCH 08/12] fix: disable AuthorizedMint/AuthorizedBurn in proptest strategy until Rosetta support The blocks_strategy is used by Rosetta proptests which still have panic stubs for these variants. Comment out the new strategies until Rosetta PR implements real handling. Co-Authored-By: Claude Opus 4.6 (1M context) --- rs/ledger_suite/icrc1/test_utils/src/lib.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/rs/ledger_suite/icrc1/test_utils/src/lib.rs b/rs/ledger_suite/icrc1/test_utils/src/lib.rs index 9428c7f1cb69..68dec9c4e18c 100644 --- a/rs/ledger_suite/icrc1/test_utils/src/lib.rs +++ b/rs/ledger_suite/icrc1/test_utils/src/lib.rs @@ -172,6 +172,10 @@ 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(), @@ -204,6 +208,7 @@ fn operation_strategy( reason, }, ); + */ prop_oneof![ mint_strategy, @@ -211,8 +216,8 @@ fn operation_strategy( transfer_strategy, approve_strategy, fee_collector_strategy, - authorized_mint_strategy, - authorized_burn_strategy, + // authorized_mint_strategy, + // authorized_burn_strategy, ] }) } From 8c03d01b86614cc4d62ebb3a5c0e1ce7850dc1d0 Mon Sep 17 00:00:00 2001 From: Bogdan Warinschi Date: Thu, 2 Apr 2026 08:50:56 +0000 Subject: [PATCH 09/12] feat(ic-icrc1-test-utils): add AuthorizedMint/AuthorizedBurn block builders Add BlockBuilder::authorized_mint() and BlockBuilder::authorized_burn() methods for constructing ICRC-122 blocks as ICRC3Value. These can be used with the icrc3_test_ledger to test index-ng and Rosetta integration with the new block types. Co-Authored-By: Claude Opus 4.6 (1M context) --- rs/ledger_suite/icrc1/test_utils/src/icrc3.rs | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) 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, From 277600ebe0949a7f55fca9e0b045d7d81266ecd2 Mon Sep 17 00:00:00 2001 From: Bogdan Warinschi Date: Thu, 2 Apr 2026 08:55:12 +0000 Subject: [PATCH 10/12] test(index-ng): add integration tests for AuthorizedMint/AuthorizedBurn via icrc3_test_ledger Test the full pipeline: load ICRC-122 blocks into the test ledger, sync index-ng, and verify: - Balances are correct (authorized mint credits, authorized burn debits) - get_account_transactions returns correct kind and operation fields - Minimal ICRC-122 blocks (no caller/mthd) are indexed correctly Co-Authored-By: Claude Opus 4.6 (1M context) --- rs/ledger_suite/icrc1/index-ng/tests/tests.rs | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/rs/ledger_suite/icrc1/index-ng/tests/tests.rs b/rs/ledger_suite/icrc1/index-ng/tests/tests.rs index 983cea50fce4..83be8b6128f6 100644 --- a/rs/ledger_suite/icrc1/index-ng/tests/tests.rs +++ b/rs/ledger_suite/icrc1/index-ng/tests/tests.rs @@ -1573,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)); From 191ced000933970b9296a0a17c5c22d605e36ae4 Mon Sep 17 00:00:00 2001 From: Bogdan Warinschi Date: Tue, 7 Apr 2026 13:56:38 +0000 Subject: [PATCH 11/12] fix: address PR #9694 review feedback - Change amt validation to is_more_or_equal_to(1) per ICRC-122 spec - Add in_memory_ledger tests verifying authorized burn does not consume allowances Co-Authored-By: Claude Opus 4.6 (1M context) --- .../icrc-ledger-types/src/icrc122/schema.rs | 2 +- .../test_utils/in_memory_ledger/src/tests.rs | 97 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/icrc-ledger-types/src/icrc122/schema.rs b/packages/icrc-ledger-types/src/icrc122/schema.rs index 3d3c4ee5ba5d..2b8b87fe5469 100644 --- a/packages/icrc-ledger-types/src/icrc122/schema.rs +++ b/packages/icrc-ledger-types/src/icrc122/schema.rs @@ -35,7 +35,7 @@ fn block_validator( is_map(), item("mthd", strict_req.clone(), is_text()), item(account_field, Required, is_account()), - item("amt", Required, is_more_or_equal_to(0)), + 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), 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..b2958499fb79 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.clone()), + &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, From baa4891fa19f55f113d0fec84c9a3059ade43aa0 Mon Sep 17 00:00:00 2001 From: Bogdan Warinschi Date: Tue, 7 Apr 2026 14:12:38 +0000 Subject: [PATCH 12/12] fix: remove clone_on_copy clippy warning in in_memory_ledger test Co-Authored-By: Claude Opus 4.6 (1M context) --- rs/ledger_suite/test_utils/in_memory_ledger/src/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b2958499fb79..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 @@ -639,7 +639,7 @@ fn should_consume_allowance_on_regular_burn_after_authorized_burn() { // Regular burn via spender — should consume from the allowance .with_burn( &account_1, - &Some(spender.clone()), + &Some(spender), &Tokens::from(regular_burn_amount), ) .build();