canic_core/interface/ic/
cycles.rs

1use crate::{
2    Error,
3    cdk::{
4        api::canister_self,
5        types::{Account, Nat, Principal, Subaccount},
6        utils::time::now_nanos,
7    },
8    env::nns::{CYCLES_MINTING_CANISTER, ICP_LEDGER_CANISTER},
9    interface::ic::{call::Call, derive_subaccount},
10    spec::{
11        ic::cycles::{IcpXdrConversionRateResponse, NotifyTopUpArgs},
12        icrc::icrc1::Icrc1TransferArgs,
13    },
14};
15
16//
17// ===========================================================================
18//  PUBLIC API
19// ===========================================================================
20//
21
22/// Fetch the ICP↔︎XDR conversion rate from the Cycles Minting Canister.
23///
24/// Returns **ICP per XDR** (not XDR per ICP).
25pub async fn get_icp_xdr_conversion_rate() -> Result<f64, Error> {
26    let res = Call::unbounded_wait(*CYCLES_MINTING_CANISTER, "get_icp_xdr_conversion_rate")
27        .await
28        .map_err(|e| Error::custom(format!("CMC.get_icp_xdr_conversion_rate failed: {e:?}")))?;
29
30    let rate_info: IcpXdrConversionRateResponse = res
31        .candid()
32        .map_err(|e| Error::custom(format!("decode error: {e:?}")))?;
33
34    // Convert permyriad XDR/ICP → ICP/XDR
35    #[allow(clippy::cast_precision_loss)]
36    let xdr_per_icp = rate_info.data.xdr_permyriad_per_icp as f64 / 10_000.0;
37
38    if xdr_per_icp <= 0.0 {
39        return Err(Error::custom(format!(
40            "invalid rate from CMC: xdr_per_icp={xdr_per_icp}"
41        )));
42    }
43
44    Ok(1.0 / xdr_per_icp)
45}
46
47/// Convert a required number of cycles into ICP, transfer ICP to the CMC,
48/// then notify CMC to mint cycles and deliver them to the caller canister.
49pub async fn convert_icp_to_cycles(
50    icp_ledger_account: Account,
51    cycles_needed: u64,
52) -> Result<(), Error> {
53    //
54    // Step 1: Fetch conversion rate and compute ICP needed (with buffer)
55    //
56    let icp_per_xdr = get_icp_xdr_conversion_rate().await?;
57    let icp_needed_e8s = calculate_icp_for_cycles(cycles_needed, icp_per_xdr);
58
59    //
60    // Step 2: Generate dedicated subaccount for this conversion
61    //
62    let caller = canister_self();
63    let subaccount = derive_subaccount(&caller, "convert_cycles");
64
65    //
66    // Step 3: Transfer ICP to CMC
67    //
68    let block_index = transfer_icp_to_cmc(icp_ledger_account, subaccount, icp_needed_e8s).await?;
69
70    //
71    // Step 4: Notify CMC to mint cycles and send to this canister
72    //
73    notify_cycles_minting_canister(block_index, caller).await?;
74
75    Ok(())
76}
77
78//
79// ===========================================================================
80//  INTERNAL HELPERS
81// ===========================================================================
82//
83
84/// Calculates the ICP (in e8s) required to mint the given number of cycles.
85///
86/// Rate interpretation: `icp_per_xdr = ICP / XDR`.
87///
88/// 1 XDR mints 1_000_000_000_000 (1e12) cycles.
89///
90/// A 5% buffer is included to accommodate temporary rate fluctuations.
91#[allow(clippy::cast_possible_truncation)]
92#[allow(clippy::cast_precision_loss)]
93#[allow(clippy::cast_sign_loss)]
94fn calculate_icp_for_cycles(cycles_needed: u64, icp_per_xdr: f64) -> u64 {
95    const CYCLES_PER_XDR: f64 = 1_000_000_000_000.0;
96    const E8S_PER_ICP: f64 = 100_000_000.0;
97    const BUFFER: f64 = 1.05;
98
99    let xdr_required = cycles_needed as f64 / CYCLES_PER_XDR;
100    let icp_required = xdr_required * icp_per_xdr * BUFFER;
101
102    (icp_required * E8S_PER_ICP).round() as u64
103}
104
105/// Perform ICRC-1 ICP transfer into the CMC’s ledger account.
106async fn transfer_icp_to_cmc(
107    from_account: Account,
108    deposit_subaccount: Subaccount,
109    icp_amount_e8s: u64,
110) -> Result<u64, Error> {
111    let args = Icrc1TransferArgs {
112        from_subaccount: from_account.subaccount,
113        to: Account {
114            owner: *CYCLES_MINTING_CANISTER,
115            subaccount: Some(deposit_subaccount),
116        },
117        amount: Nat::from(icp_amount_e8s),
118        fee: None,
119        memo: Some(b"cycle_conversion".to_vec()),
120        created_at_time: Some(now_nanos()),
121    };
122
123    let raw = Call::unbounded_wait(*ICP_LEDGER_CANISTER, "icrc1_transfer")
124        .with_args(&(args,))
125        .await
126        .map_err(|e| Error::custom(format!("ICP ledger transfer failed: {e:?}")))?;
127
128    // The ICRC-1 ledger returns Result<Nat, Nat>.
129    let result: Result<Nat, Nat> = raw
130        .candid()
131        .map_err(|e| Error::custom(format!("Failed to decode icrc1_transfer(): {e:?}")))?;
132
133    match result {
134        Ok(block) => block
135            .0
136            .try_into()
137            .map_err(|_| Error::custom("transfer block index does not fit into u64")),
138        Err(err_code) => Err(Error::custom(format!(
139            "ICP transfer rejected by ledger: {err_code}"
140        ))),
141    }
142}
143
144/// Notify the Cycles Minting Canister to convert previously deposited ICP.
145///
146/// The CMC reads the deposit subaccount from the ledger block.
147async fn notify_cycles_minting_canister(
148    block_index: u64,
149    recipient: Principal,
150) -> Result<(), Error> {
151    let args = NotifyTopUpArgs {
152        block_index,
153        canister_id: recipient,
154    };
155
156    let raw = Call::unbounded_wait(*CYCLES_MINTING_CANISTER, "notify_top_up")
157        .with_args(&(args,))
158        .await
159        .map_err(|e| Error::custom(format!("CMC.notify_top_up transport failure: {e:?}")))?;
160
161    let method_result: Result<(), Error> = raw
162        .candid()
163        .map_err(|e| Error::custom(format!("decode failure in notify_top_up: {e:?}")))?;
164
165    method_result.map_err(|e| Error::custom(format!("CMC rejected notify_top_up: {e:?}")))
166}
167
168//
169// ===========================================================================
170//  TESTS
171// ===========================================================================
172//
173
174#[cfg(test)]
175mod tests {
176    use super::calculate_icp_for_cycles;
177
178    #[test]
179    fn calculates_icp_for_cycles_with_buffer() {
180        let cycles_needed = 2_000_000_000_000u64; // 2 XDR
181        let icp_per_xdr = 1.25_f64; // XDR costs 1.25 ICP
182        let icp_e8s = calculate_icp_for_cycles(cycles_needed, icp_per_xdr);
183        assert_eq!(icp_e8s, 262_500_000); // 2 * 1.25 * 1.05 = 2.625 ICP
184    }
185}