Skip to content

Commit 9999570

Browse files
bogwarclaude
andcommitted
feat(ic-icrc1): add AuthorizedMint and AuthorizedBurn operation variants
Adds two new Operation variants for ICRC-152 controller-privileged supply changes: - AuthorizedMint { to, amount, caller, reason } → produces a 122mint block - AuthorizedBurn { from, amount, caller, reason } → produces a 122burn block Changes: - FlattenedTransaction gains an optional `reason` field (serde skip if None) - Serialization/deserialization roundtrips through btype "122mint"/"122burn" - Block::from_transaction sets btype for the new variants - apply() delegates to balances.mint/burn with no fee logic - endpoints.rs get_transactions maps the new ops to kind "122mint"/"122burn" (no legacy typed fields; callers should prefer icrc3_get_blocks) Unit tests cover: CBOR roundtrip, btype assignment, block encode/decode. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 70e48a9 commit 9999570

File tree

2 files changed

+224
-4
lines changed

2 files changed

+224
-4
lines changed

rs/ledger_suite/icrc1/src/endpoints.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,16 @@ impl<Tokens: TokensType> From<Block<Tokens>> for Transaction {
248248
mthd,
249249
});
250250
}
251+
// AuthorizedMint and AuthorizedBurn are ICRC-152 operations exposed via
252+
// icrc3_get_blocks (ICRC-3). The legacy get_transactions endpoint does not
253+
// have a representation for them, so we set the kind and leave all typed
254+
// fields as None.
255+
Operation::AuthorizedMint { .. } => {
256+
tx.kind = "122mint".to_string();
257+
}
258+
Operation::AuthorizedBurn { .. } => {
259+
tx.kind = "122burn".to_string();
260+
}
251261
}
252262

253263
tx

rs/ledger_suite/icrc1/src/lib.rs

Lines changed: 214 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,22 @@ pub enum Operation<Tokens: TokensType> {
5454
caller: Option<Principal>,
5555
mthd: Option<String>,
5656
},
57+
/// A mint performed by a ledger controller via ICRC-152 (`icrc152_mint`).
58+
/// Produces a `122mint` block.
59+
AuthorizedMint {
60+
to: Account,
61+
amount: Tokens,
62+
caller: Principal,
63+
reason: Option<String>,
64+
},
65+
/// A burn performed by a ledger controller via ICRC-152 (`icrc152_burn`).
66+
/// Produces a `122burn` block.
67+
AuthorizedBurn {
68+
from: Account,
69+
amount: Tokens,
70+
caller: Principal,
71+
reason: Option<String>,
72+
},
5773
}
5874

5975
// A [Transaction] but flattened meaning that [Operation]
@@ -122,6 +138,10 @@ struct FlattenedTransaction<Tokens: TokensType> {
122138
#[serde(default)]
123139
#[serde(skip_serializing_if = "Option::is_none")]
124140
pub mthd: Option<String>,
141+
142+
#[serde(default)]
143+
#[serde(skip_serializing_if = "Option::is_none")]
144+
pub reason: Option<String>,
125145
}
126146

127147
impl<Tokens: TokensType> TryFrom<FlattenedTransaction<Tokens>> for Transaction<Tokens> {
@@ -162,6 +182,22 @@ impl<Tokens: TokensType> TryFrom<(Option<String>, FlattenedTransaction<Tokens>)>
162182
caller: value.caller,
163183
mthd: value.mthd,
164184
},
185+
Some("122mint") => Operation::AuthorizedMint {
186+
to: value.to.ok_or("`to` required for `122mint` block")?,
187+
amount: value.amount.ok_or("`amt` required for `122mint` block")?,
188+
caller: value
189+
.caller
190+
.ok_or("`caller` required for `122mint` block")?,
191+
reason: value.reason,
192+
},
193+
Some("122burn") => Operation::AuthorizedBurn {
194+
from: value.from.ok_or("`from` required for `122burn` block")?,
195+
amount: value.amount.ok_or("`amt` required for `122burn` block")?,
196+
caller: value
197+
.caller
198+
.ok_or("`caller` required for `122burn` block")?,
199+
reason: value.reason,
200+
},
165201
_ => Operation::try_from(value)
166202
.map_err(|e| format!("{} and/or unknown btype {:?}", e, btype_str))?,
167203
};
@@ -241,14 +277,16 @@ impl<Tokens: TokensType> From<Transaction<Tokens>> for FlattenedTransaction<Toke
241277
Mint { .. } => Some("mint".to_string()),
242278
Transfer { .. } => Some("xfer".to_string()),
243279
Approve { .. } => Some("approve".to_string()),
244-
FeeCollector { .. } => None,
280+
FeeCollector { .. } | AuthorizedMint { .. } | AuthorizedBurn { .. } => None,
245281
},
246282
from: match &t.operation {
247283
Transfer { from, .. } | Burn { from, .. } | Approve { from, .. } => Some(*from),
284+
AuthorizedBurn { from, .. } => Some(*from),
248285
_ => None,
249286
},
250287
to: match &t.operation {
251288
Mint { to, .. } | Transfer { to, .. } => Some(*to),
289+
AuthorizedMint { to, .. } => Some(*to),
252290
_ => None,
253291
},
254292
spender: match &t.operation {
@@ -261,14 +299,17 @@ impl<Tokens: TokensType> From<Transaction<Tokens>> for FlattenedTransaction<Toke
261299
| Mint { amount, .. }
262300
| Transfer { amount, .. }
263301
| Approve { amount, .. } => Some(amount.clone()),
302+
AuthorizedMint { amount, .. } | AuthorizedBurn { amount, .. } => {
303+
Some(amount.clone())
304+
}
264305
FeeCollector { .. } => None,
265306
},
266307
fee: match &t.operation {
267308
Transfer { fee, .. }
268309
| Approve { fee, .. }
269310
| Mint { fee, .. }
270311
| Burn { fee, .. } => fee.to_owned(),
271-
FeeCollector { .. } => None,
312+
FeeCollector { .. } | AuthorizedMint { .. } | AuthorizedBurn { .. } => None,
272313
},
273314
expected_allowance: match &t.operation {
274315
Approve {
@@ -285,13 +326,18 @@ impl<Tokens: TokensType> From<Transaction<Tokens>> for FlattenedTransaction<Toke
285326
_ => None,
286327
},
287328
caller: match &t.operation {
288-
FeeCollector { caller, .. } => caller.to_owned(),
329+
FeeCollector { caller, .. } => *caller,
330+
AuthorizedMint { caller, .. } | AuthorizedBurn { caller, .. } => Some(*caller),
289331
_ => None,
290332
},
291333
mthd: match &t.operation {
292334
FeeCollector { mthd, .. } => mthd.to_owned(),
293335
_ => None,
294336
},
337+
reason: match &t.operation {
338+
AuthorizedMint { reason, .. } | AuthorizedBurn { reason, .. } => reason.clone(),
339+
_ => None,
340+
},
295341
}
296342
}
297343
}
@@ -485,6 +531,12 @@ impl<Tokens: TokensType> LedgerTransaction for Transaction<Tokens> {
485531
Operation::FeeCollector { .. } => {
486532
panic!("FeeCollector107 not implemented")
487533
}
534+
Operation::AuthorizedMint { to, amount, .. } => {
535+
context.balances_mut().mint(to, amount.clone())?;
536+
}
537+
Operation::AuthorizedBurn { from, amount, .. } => {
538+
context.balances_mut().burn(from, amount.clone())?;
539+
}
488540
}
489541
Ok(())
490542
}
@@ -669,6 +721,11 @@ impl<Tokens: TokensType> BlockType for Block<Tokens> {
669721
effective_fee: Tokens,
670722
fee_collector: Option<FeeCollector<Self::AccountId>>,
671723
) -> Self {
724+
let btype = match &transaction.operation {
725+
Operation::AuthorizedMint { .. } => Some("122mint".to_string()),
726+
Operation::AuthorizedBurn { .. } => Some("122burn".to_string()),
727+
_ => None,
728+
};
672729
let effective_fee = match &transaction.operation {
673730
Operation::Transfer { fee, .. } => fee.is_none().then_some(effective_fee),
674731
Operation::Approve { fee, .. } => fee.is_none().then_some(effective_fee),
@@ -692,10 +749,163 @@ impl<Tokens: TokensType> BlockType for Block<Tokens> {
692749
timestamp: timestamp.as_nanos_since_unix_epoch(),
693750
fee_collector,
694751
fee_collector_block_index,
695-
btype: None,
752+
btype,
696753
}
697754
}
698755
}
699756

700757
pub type LedgerBalances<Tokens> = Balances<BTreeMap<Account, Tokens>>;
701758
pub type LedgerAllowances<Tokens> = AllowanceTable<HeapAllowancesData<Account, Tokens>>;
759+
760+
#[cfg(test)]
761+
mod tests {
762+
use super::*;
763+
use candid::Principal;
764+
use ic_icrc1_tokens_u64::U64;
765+
use ic_ledger_core::block::BlockType;
766+
use ic_ledger_hash_of::HashOf;
767+
768+
type Tokens = U64;
769+
770+
fn controller() -> Principal {
771+
Principal::from_slice(&[1u8; 29])
772+
}
773+
774+
fn account(seed: u8) -> Account {
775+
Account {
776+
owner: Principal::from_slice(&[seed; 29]),
777+
subaccount: None,
778+
}
779+
}
780+
781+
fn make_authorized_mint_tx(created_at_time: Option<u64>) -> Transaction<Tokens> {
782+
Transaction {
783+
operation: Operation::AuthorizedMint {
784+
to: account(2),
785+
amount: U64::from(1000u64),
786+
caller: controller(),
787+
reason: Some("test mint".to_string()),
788+
},
789+
created_at_time,
790+
memo: None,
791+
}
792+
}
793+
794+
fn make_authorized_burn_tx(created_at_time: Option<u64>) -> Transaction<Tokens> {
795+
Transaction {
796+
operation: Operation::AuthorizedBurn {
797+
from: account(2),
798+
amount: U64::from(500u64),
799+
caller: controller(),
800+
reason: None,
801+
},
802+
created_at_time,
803+
memo: None,
804+
}
805+
}
806+
807+
/// Serialise a Transaction via its FlattenedTransaction impl and deserialise it back.
808+
fn cbor_roundtrip(tx: &Transaction<Tokens>) -> Transaction<Tokens> {
809+
let mut buf = vec![];
810+
ciborium::ser::into_writer(tx, &mut buf).expect("serialise");
811+
ciborium::de::from_reader(buf.as_slice()).expect("deserialise")
812+
}
813+
814+
#[test]
815+
fn authorized_mint_cbor_roundtrip() {
816+
let tx = make_authorized_mint_tx(Some(1_000_000_000));
817+
let recovered = cbor_roundtrip(&tx);
818+
assert_eq!(tx, recovered);
819+
}
820+
821+
#[test]
822+
fn authorized_burn_cbor_roundtrip() {
823+
let tx = make_authorized_burn_tx(Some(2_000_000_000));
824+
let recovered = cbor_roundtrip(&tx);
825+
assert_eq!(tx, recovered);
826+
}
827+
828+
#[test]
829+
fn authorized_mint_cbor_roundtrip_no_reason() {
830+
let tx = Transaction {
831+
operation: Operation::AuthorizedMint {
832+
to: account(3),
833+
amount: U64::from(42u64),
834+
caller: controller(),
835+
reason: None,
836+
},
837+
created_at_time: None,
838+
memo: None,
839+
};
840+
assert_eq!(tx, cbor_roundtrip(&tx));
841+
}
842+
843+
#[test]
844+
fn authorized_mint_block_has_122mint_btype() {
845+
let tx = make_authorized_mint_tx(None);
846+
let block = Block::<Tokens>::from_transaction(
847+
None,
848+
tx,
849+
TimeStamp::from_nanos_since_unix_epoch(1_000_000_000),
850+
Tokens::from(0u64),
851+
None,
852+
);
853+
assert_eq!(block.btype.as_deref(), Some("122mint"));
854+
}
855+
856+
#[test]
857+
fn authorized_burn_block_has_122burn_btype() {
858+
let tx = make_authorized_burn_tx(None);
859+
let block = Block::<Tokens>::from_transaction(
860+
None,
861+
tx,
862+
TimeStamp::from_nanos_since_unix_epoch(1_000_000_000),
863+
Tokens::from(0u64),
864+
None,
865+
);
866+
assert_eq!(block.btype.as_deref(), Some("122burn"));
867+
}
868+
869+
#[test]
870+
fn regular_mint_block_has_no_btype() {
871+
let tx = Transaction::<Tokens>::mint(account(2), Tokens::from(100u64), None, None);
872+
let block = Block::<Tokens>::from_transaction(
873+
None,
874+
tx,
875+
TimeStamp::from_nanos_since_unix_epoch(1_000_000_000),
876+
Tokens::from(0u64),
877+
None,
878+
);
879+
assert_eq!(block.btype, None);
880+
}
881+
882+
#[test]
883+
fn block_encode_decode_roundtrip_authorized_mint() {
884+
let tx = make_authorized_mint_tx(Some(999_999));
885+
let block = Block::<Tokens>::from_transaction(
886+
Some(HashOf::new([0u8; 32])),
887+
tx,
888+
TimeStamp::from_nanos_since_unix_epoch(1_000_000_000),
889+
Tokens::from(0u64),
890+
None,
891+
);
892+
let encoded = block.clone().encode();
893+
let decoded = Block::<Tokens>::decode(encoded).expect("decode failed");
894+
assert_eq!(block, decoded);
895+
}
896+
897+
#[test]
898+
fn block_encode_decode_roundtrip_authorized_burn() {
899+
let tx = make_authorized_burn_tx(Some(888_888));
900+
let block = Block::<Tokens>::from_transaction(
901+
None,
902+
tx,
903+
TimeStamp::from_nanos_since_unix_epoch(2_000_000_000),
904+
Tokens::from(0u64),
905+
None,
906+
);
907+
let encoded = block.clone().encode();
908+
let decoded = Block::<Tokens>::decode(encoded).expect("decode failed");
909+
assert_eq!(block, decoded);
910+
}
911+
}

0 commit comments

Comments
 (0)