Skip to content

Commit f7ae436

Browse files
bogwarclaude
andcommitted
feat(ic-icrc-rosetta): add ICRC-122 AuthorizedMint/AuthorizedBurn Rosetta support
- Add AuthorizedMint/AuthorizedBurn variants to IcrcOperation and OperationType - Add AuthorizedOperationMetadata for caller/mthd/reason in Rosetta operations - Implement balance updates (credit for mint, debit for burn) - Implement bidirectional Rosetta operation mapping with metadata round-trip - Construction API: bail for non-constructable privileged operations - Re-enable AuthorizedMint/AuthorizedBurn in proptest blocks_strategy - Add compare_blocks test arms for proptest round-trip validation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent baa4891 commit f7ae436

File tree

10 files changed

+449
-24
lines changed

10 files changed

+449
-24
lines changed

rs/ledger_suite/icrc1/test_utils/src/lib.rs

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -172,10 +172,6 @@ fn operation_strategy<Tokens: TokensType>(
172172
},
173173
);
174174

175-
// TODO: AuthorizedMint/AuthorizedBurn strategies are commented out until
176-
// Rosetta support is implemented (PR 4). Rosetta proptests use blocks_strategy
177-
// and will panic on these variants. Re-enable after Rosetta handles them.
178-
/*
179175
let authorized_mint_amount = amount.clone();
180176
let authorized_mint_strategy = (
181177
account_strategy(),
@@ -208,16 +204,15 @@ fn operation_strategy<Tokens: TokensType>(
208204
reason,
209205
},
210206
);
211-
*/
212207

213208
prop_oneof![
214209
mint_strategy,
215210
burn_strategy,
216211
transfer_strategy,
217212
approve_strategy,
218213
fee_collector_strategy,
219-
// authorized_mint_strategy,
220-
// authorized_burn_strategy,
214+
authorized_mint_strategy,
215+
authorized_burn_strategy,
221216
]
222217
})
223218
}

rs/rosetta-api/icrc1/src/common/storage/storage_operations/mod.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,36 @@ pub fn update_account_balances(
385385
)?;
386386
}
387387
}
388+
crate::common::storage::types::IcrcOperation::AuthorizedMint {
389+
to,
390+
amount,
391+
caller: _,
392+
mthd: _,
393+
reason: _,
394+
} => {
395+
credit(
396+
to,
397+
amount,
398+
rosetta_block.index,
399+
connection,
400+
&mut account_balances_cache,
401+
)?;
402+
}
403+
crate::common::storage::types::IcrcOperation::AuthorizedBurn {
404+
from,
405+
amount,
406+
caller: _,
407+
mthd: _,
408+
reason: _,
409+
} => {
410+
debit(
411+
from,
412+
amount,
413+
rosetta_block.index,
414+
connection,
415+
&mut account_balances_cache,
416+
)?;
417+
}
388418
crate::common::storage::types::IcrcOperation::Approve {
389419
from,
390420
spender: _,
@@ -613,6 +643,34 @@ pub fn store_blocks(
613643
None,
614644
None,
615645
),
646+
crate::common::storage::types::IcrcOperation::AuthorizedMint { to, amount, .. } => (
647+
"authorized_mint",
648+
None,
649+
None,
650+
Some(to.owner),
651+
Some(*to.effective_subaccount()),
652+
None,
653+
None,
654+
amount,
655+
None,
656+
None,
657+
None,
658+
),
659+
crate::common::storage::types::IcrcOperation::AuthorizedBurn {
660+
from, amount, ..
661+
} => (
662+
"authorized_burn",
663+
Some(from.owner),
664+
Some(*from.effective_subaccount()),
665+
None,
666+
None,
667+
None,
668+
None,
669+
amount,
670+
None,
671+
None,
672+
None,
673+
),
616674
};
617675

618676
// SQLite doesn't support unsigned 64-bit integers. We need to convert the timestamps to signed

rs/rosetta-api/icrc1/src/common/storage/types.rs

Lines changed: 156 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use icrc_ledger_types::icrc::generic_metadata_value::MetadataValue;
88
use icrc_ledger_types::icrc::metadata_key::MetadataKey;
99
use icrc_ledger_types::icrc3::blocks::GenericBlock;
1010
use icrc_ledger_types::icrc107::schema::BTYPE_107;
11+
use icrc_ledger_types::icrc122::schema::{BTYPE_122_BURN, BTYPE_122_MINT};
1112
use icrc_ledger_types::{
1213
icrc::generic_value::Value,
1314
icrc1::{account::Account, transfer::Memo},
@@ -138,7 +139,9 @@ impl RosettaBlock {
138139
IcrcOperation::Transfer { fee, .. } => fee,
139140
IcrcOperation::Approve { fee, .. } => fee,
140141
IcrcOperation::Burn { fee, .. } => fee,
141-
IcrcOperation::FeeCollector { .. } => None,
142+
IcrcOperation::FeeCollector { .. }
143+
| IcrcOperation::AuthorizedMint { .. }
144+
| IcrcOperation::AuthorizedBurn { .. } => None,
142145
}))
143146
}
144147

@@ -378,6 +381,20 @@ pub enum IcrcOperation {
378381
caller: Option<Principal>,
379382
mthd: Option<String>,
380383
},
384+
AuthorizedMint {
385+
to: Account,
386+
amount: Nat,
387+
caller: Option<Principal>,
388+
mthd: Option<String>,
389+
reason: Option<String>,
390+
},
391+
AuthorizedBurn {
392+
from: Account,
393+
amount: Nat,
394+
caller: Option<Principal>,
395+
mthd: Option<String>,
396+
reason: Option<String>,
397+
},
381398
}
382399

383400
impl TryFrom<(Option<String>, BTreeMap<String, Value>)> for IcrcOperation {
@@ -455,6 +472,36 @@ impl TryFrom<(Option<String>, BTreeMap<String, Value>)> for IcrcOperation {
455472
mthd,
456473
})
457474
}
475+
BTYPE_122_MINT => {
476+
let to: Account = get_field(&map, FIELD_PREFIX, "to")?;
477+
let caller: Option<Principal> = get_opt_field(&map, FIELD_PREFIX, "caller")?;
478+
let mthd: Option<String> = get_opt_field(&map, FIELD_PREFIX, "mthd")?;
479+
let reason: Option<String> = get_opt_field(&map, FIELD_PREFIX, "reason")?;
480+
Ok(Self::AuthorizedMint {
481+
to,
482+
amount: amount.ok_or_else(|| {
483+
anyhow!("Missing field 'amt' for AuthorizedMint operation")
484+
})?,
485+
caller,
486+
mthd,
487+
reason,
488+
})
489+
}
490+
BTYPE_122_BURN => {
491+
let from: Account = get_field(&map, FIELD_PREFIX, "from")?;
492+
let caller: Option<Principal> = get_opt_field(&map, FIELD_PREFIX, "caller")?;
493+
let mthd: Option<String> = get_opt_field(&map, FIELD_PREFIX, "mthd")?;
494+
let reason: Option<String> = get_opt_field(&map, FIELD_PREFIX, "reason")?;
495+
Ok(Self::AuthorizedBurn {
496+
from,
497+
amount: amount.ok_or_else(|| {
498+
anyhow!("Missing field 'amt' for AuthorizedBurn operation")
499+
})?,
500+
caller,
501+
mthd,
502+
reason,
503+
})
504+
}
458505
found => {
459506
bail!(
460507
"Expected field 'op' to be 'burn', 'mint', 'xfer' or 'approve' but found {found}"
@@ -554,6 +601,44 @@ impl From<IcrcOperation> for BTreeMap<String, Value> {
554601
map.insert("mthd".to_string(), Value::text(mthd));
555602
}
556603
}
604+
Op::AuthorizedMint {
605+
to,
606+
amount,
607+
caller,
608+
mthd,
609+
reason,
610+
} => {
611+
map.insert("to".to_string(), Value::from(to));
612+
map.insert("amt".to_string(), Value::Nat(amount));
613+
if let Some(caller) = caller {
614+
map.insert("caller".to_string(), Value::from(caller));
615+
}
616+
if let Some(mthd) = mthd {
617+
map.insert("mthd".to_string(), Value::text(mthd));
618+
}
619+
if let Some(reason) = reason {
620+
map.insert("reason".to_string(), Value::text(reason));
621+
}
622+
}
623+
Op::AuthorizedBurn {
624+
from,
625+
amount,
626+
caller,
627+
mthd,
628+
reason,
629+
} => {
630+
map.insert("from".to_string(), Value::from(from));
631+
map.insert("amt".to_string(), Value::Nat(amount));
632+
if let Some(caller) = caller {
633+
map.insert("caller".to_string(), Value::from(caller));
634+
}
635+
if let Some(mthd) = mthd {
636+
map.insert("mthd".to_string(), Value::text(mthd));
637+
}
638+
if let Some(reason) = reason {
639+
map.insert("reason".to_string(), Value::text(reason));
640+
}
641+
}
557642
}
558643
map
559644
}
@@ -676,9 +761,32 @@ where
676761
caller,
677762
mthd,
678763
},
679-
Op::AuthorizedMint { .. } | Op::AuthorizedBurn { .. } => {
680-
panic!("AuthorizedMint/AuthorizedBurn not yet supported in Rosetta")
681-
}
764+
Op::AuthorizedMint {
765+
to,
766+
amount,
767+
caller,
768+
mthd,
769+
reason,
770+
} => Self::AuthorizedMint {
771+
to,
772+
amount: amount.into(),
773+
caller,
774+
mthd,
775+
reason,
776+
},
777+
Op::AuthorizedBurn {
778+
from,
779+
amount,
780+
caller,
781+
mthd,
782+
reason,
783+
} => Self::AuthorizedBurn {
784+
from,
785+
amount: amount.into(),
786+
caller,
787+
mthd,
788+
reason,
789+
},
682790
}
683791
}
684792
}
@@ -1100,6 +1208,50 @@ mod tests {
11001208
assert_eq!(caller, rosetta_caller, "caller");
11011209
assert_eq!(mthd, rosetta_mthd, "mthd");
11021210
}
1211+
(
1212+
ic_icrc1::Operation::AuthorizedMint {
1213+
to,
1214+
amount,
1215+
caller,
1216+
mthd,
1217+
reason,
1218+
},
1219+
IcrcOperation::AuthorizedMint {
1220+
to: rosetta_to,
1221+
amount: rosetta_amount,
1222+
caller: rosetta_caller,
1223+
mthd: rosetta_mthd,
1224+
reason: rosetta_reason,
1225+
},
1226+
) => {
1227+
assert_eq!(to, rosetta_to, "to");
1228+
assert_eq!(amount.into(), rosetta_amount, "amount");
1229+
assert_eq!(caller, rosetta_caller, "caller");
1230+
assert_eq!(mthd, rosetta_mthd, "mthd");
1231+
assert_eq!(reason, rosetta_reason, "reason");
1232+
}
1233+
(
1234+
ic_icrc1::Operation::AuthorizedBurn {
1235+
from,
1236+
amount,
1237+
caller,
1238+
mthd,
1239+
reason,
1240+
},
1241+
IcrcOperation::AuthorizedBurn {
1242+
from: rosetta_from,
1243+
amount: rosetta_amount,
1244+
caller: rosetta_caller,
1245+
mthd: rosetta_mthd,
1246+
reason: rosetta_reason,
1247+
},
1248+
) => {
1249+
assert_eq!(from, rosetta_from, "from");
1250+
assert_eq!(amount.into(), rosetta_amount, "amount");
1251+
assert_eq!(caller, rosetta_caller, "caller");
1252+
assert_eq!(mthd, rosetta_mthd, "mthd");
1253+
assert_eq!(reason, rosetta_reason, "reason");
1254+
}
11031255
(l, r) => panic!(
11041256
"Found different type of operations. Operation:{l:?} rosetta's Operation:{r:?}"
11051257
),

rs/rosetta-api/icrc1/src/common/types.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,8 @@ pub enum OperationType {
201201
Approve,
202202
Fee,
203203
FeeCollector,
204+
AuthorizedMint,
205+
AuthorizedBurn,
204206
}
205207

206208
#[derive(Clone, Eq, PartialEq, Debug, Deserialize, Serialize)]
@@ -246,6 +248,52 @@ impl TryFrom<Option<ObjectMap>> for ApproveMetadata {
246248
}
247249
}
248250

251+
#[derive(Clone, Eq, PartialEq, Debug, Deserialize, Serialize)]
252+
pub struct AuthorizedOperationMetadata {
253+
#[serde(skip_serializing_if = "Option::is_none")]
254+
pub caller: Option<String>,
255+
256+
#[serde(skip_serializing_if = "Option::is_none")]
257+
pub mthd: Option<String>,
258+
259+
#[serde(skip_serializing_if = "Option::is_none")]
260+
pub reason: Option<String>,
261+
}
262+
263+
impl TryFrom<AuthorizedOperationMetadata> for ObjectMap {
264+
type Error = anyhow::Error;
265+
fn try_from(d: AuthorizedOperationMetadata) -> Result<ObjectMap, Self::Error> {
266+
match serde_json::to_value(d) {
267+
Ok(serde_json::Value::Object(ob)) => Ok(ob),
268+
Ok(v) => anyhow::bail!(
269+
"Could not convert AuthorizedOperationMetadata to ObjectMap. Expected Object but received: {:?}",
270+
v
271+
),
272+
Err(err) => anyhow::bail!(
273+
"Could not convert AuthorizedOperationMetadata to ObjectMap: {:?}",
274+
err
275+
),
276+
}
277+
}
278+
}
279+
280+
impl TryFrom<ObjectMap> for AuthorizedOperationMetadata {
281+
type Error = anyhow::Error;
282+
fn try_from(o: ObjectMap) -> anyhow::Result<Self> {
283+
serde_json::from_value(serde_json::Value::Object(o.clone())).with_context(|| {
284+
format!("Could not parse AuthorizedOperationMetadata from Object: {o:?}")
285+
})
286+
}
287+
}
288+
289+
impl TryFrom<Option<ObjectMap>> for AuthorizedOperationMetadata {
290+
type Error = anyhow::Error;
291+
fn try_from(o: Option<ObjectMap>) -> anyhow::Result<Self> {
292+
serde_json::from_value(serde_json::Value::Object(o.unwrap_or_default()))
293+
.context("Could not parse AuthorizedOperationMetadata from JSON object")
294+
}
295+
}
296+
249297
#[derive(Clone, Eq, PartialEq, Debug, Deserialize, Serialize)]
250298
pub struct TransactionMetadata {
251299
#[serde(skip_serializing_if = "Option::is_none")]

0 commit comments

Comments
 (0)