Skip to main content

canic_host/fleet_ensure/ops/operator_mint/transport/
mod.rs

1//! Module: fleet_ensure::ops::operator_mint::transport
2//!
3//! Responsibility: bounded exact calls and authenticated receipt acquisition.
4//! Boundary: workflow must retain approval before any payment or notification.
5
6use crate::{
7    fleet_ensure::{
8        model::operator_mint::{OperatorMintIntentRecord, OperatorMintNotificationOutcomeRecord},
9        ops::operator_mint::receipts::{
10            self, CyclesLedgerBlock, DepositVerificationError, ReceiptVerificationLimits,
11            VerifiedCyclesDeposit,
12            icp::{
13                self, IcpReadError, IcpReadLimits, IcpReadOutcome, IcpTransferRead,
14                VerifiedIcpTransfer,
15            },
16        },
17        view::operator_mint::OperatorMintRateQuote,
18    },
19    icp::{IcpCli, IcpManagementCallError},
20};
21use candid::{CandidType, Nat, Principal};
22use ic_agent::{Agent, AgentError, agent::RequestStatusResponse};
23use icrc_ledger_types::icrc3::blocks::{GetBlocksRequest, ICRC3DataCertificate};
24use serde::{Deserialize, de::DeserializeOwned};
25use std::time::Duration;
26use thiserror::Error;
27
28#[derive(CandidType, Deserialize)]
29struct Rate {
30    timestamp_seconds: u64,
31    xdr_permyriad_per_icp: u64,
32}
33
34#[derive(CandidType, Deserialize)]
35struct RateReply {
36    data: Rate,
37}
38
39// These are bounded observation budgets, not a payment validity window.
40const RESPONSE_BYTES: usize = 4 * 1024 * 1024;
41const CALL_TIMEOUT: Duration = Duration::from_secs(120);
42const DEPOSIT_LIMITS: ReceiptVerificationLimits = ReceiptVerificationLimits {
43    certificate_bytes: 256 * 1024,
44    blocks: 1024,
45    value_nodes: 100_000,
46    value_bytes: RESPONSE_BYTES,
47    depth: 32,
48};
49const ICP_LIMITS: IcpReadLimits = IcpReadLimits {
50    certificate_bytes: RESPONSE_BYTES,
51    certificate_depth: 32,
52    reply_bytes: 1024 * 1024,
53    decoding_quota: 2_000_000,
54    skipping_quota: 100_000,
55};
56
57///
58/// OperatorMintTransportError
59///
60/// Ops-owned bounded observation failure; none permits a replacement payment.
61///
62#[derive(Debug, Error)]
63pub enum OperatorMintTransportError {
64    #[error(transparent)]
65    Agent(#[from] AgentError),
66    #[error("operator mint response exceeded its observation budget")]
67    BudgetExceeded,
68    #[error("operator mint response encoding or decoding failed")]
69    Candid(#[from] candid::Error),
70    #[error(transparent)]
71    Deposit(#[from] DepositVerificationError),
72    #[error(transparent)]
73    Identity(Box<IcpManagementCallError>),
74    #[error(transparent)]
75    Icp(Box<IcpReadError>),
76    #[error("operator mint receipt remains unavailable; retain the original intent")]
77    ReceiptUnavailable,
78    #[error("operator mint async runtime could not start")]
79    Runtime(#[from] std::io::Error),
80    #[error("operator mint selected network or signer differs from the retained review")]
81    ReaderMismatch,
82    #[error("operator mint response could not be serialized for verification")]
83    Serialization,
84    #[error("operator mint call timed out; its outcome remains unresolved")]
85    Timeout,
86    #[error(transparent)]
87    Wire(#[from] crate::fleet_ensure::ops::operator_mint::OperatorMintWireError),
88}
89
90impl From<IcpManagementCallError> for OperatorMintTransportError {
91    fn from(error: IcpManagementCallError) -> Self {
92        Self::Identity(Box::new(error))
93    }
94}
95
96impl From<IcpReadError> for OperatorMintTransportError {
97    fn from(error: IcpReadError) -> Self {
98        Self::Icp(Box::new(error))
99    }
100}
101
102///
103/// OperatorMintTransport
104///
105/// Ops-owned Agent fixed to the selected signer, network and HTTP byte budget.
106///
107pub struct OperatorMintTransport {
108    agent: Agent,
109}
110
111#[derive(CandidType, Deserialize)]
112struct BlocksReply {
113    blocks: Vec<CyclesLedgerBlock>,
114}
115
116impl OperatorMintTransport {
117    /// Read only the selected operator's default Cycles Ledger account.
118    pub fn operator_balance(&self, ledger: Principal) -> Result<u128, OperatorMintTransportError> {
119        tokio::runtime::Runtime::new()?.block_on(self.read_operator_balance(ledger))
120    }
121
122    /// Async observation of the same default account; not receipt evidence.
123    pub async fn read_operator_balance(
124        &self,
125        ledger: Principal,
126    ) -> Result<u128, OperatorMintTransportError> {
127        let account = icrc_ledger_types::icrc1::account::Account {
128            owner: self.operator()?,
129            subaccount: None,
130        };
131        let amount: Nat = self
132            .query(ledger, "icrc1_balance_of", candid::encode_one(account)?)
133            .await?;
134        (&amount.0)
135            .try_into()
136            .map_err(|_| OperatorMintTransportError::BudgetExceeded)
137    }
138    /// Read the advisory quote for a synchronous host command.
139    pub fn quote_blocking(
140        &self,
141        icp_ledger: Principal,
142        cmc: Principal,
143        cycles_ledger: Principal,
144    ) -> Result<OperatorMintRateQuote, OperatorMintTransportError> {
145        tokio::runtime::Runtime::new()?.block_on(self.quote(icp_ledger, cmc, cycles_ledger))
146    }
147    #[cfg(test)]
148    pub(in crate::fleet_ensure) const fn from_test_agent(agent: Agent) -> Self {
149        Self { agent }
150    }
151
152    /// Observe fees and an advisory CMC rate without submitting any payment.
153    pub async fn quote(
154        &self,
155        icp_ledger: Principal,
156        cmc: Principal,
157        cycles_ledger: Principal,
158    ) -> Result<OperatorMintRateQuote, OperatorMintTransportError> {
159        let rate: RateReply = self
160            .query(cmc, "get_icp_xdr_conversion_rate", candid::encode_args(())?)
161            .await?;
162        let transfer: Nat = self
163            .query(icp_ledger, "icrc1_fee", candid::encode_args(())?)
164            .await?;
165        let deposit: Nat = self
166            .query(cycles_ledger, "icrc1_fee", candid::encode_args(())?)
167            .await?;
168        Ok(OperatorMintRateQuote {
169            rate_timestamp_seconds: rate.data.timestamp_seconds,
170            xdr_permyriad_per_icp: rate.data.xdr_permyriad_per_icp,
171            transfer_fee_e8s: (&transfer.0)
172                .try_into()
173                .map_err(|_| OperatorMintTransportError::BudgetExceeded)?,
174            estimated_deposit_fee_cycles: (&deposit.0)
175                .try_into()
176                .map_err(|_| OperatorMintTransportError::BudgetExceeded)?,
177        })
178    }
179    /// Resolve the existing ICP identity and trusted network; no payment occurs.
180    pub fn from_icp(icp: &IcpCli) -> Result<Self, OperatorMintTransportError> {
181        Ok(Self {
182            agent: icp.authenticated_agent_with_response_limit(RESPONSE_BYTES)?,
183        })
184    }
185
186    /// Selected trust-anchor identity for the effect-free review.
187    #[must_use]
188    pub fn network_identity(&self) -> [u8; 32] {
189        receipts::network_identity_sha256(&self.agent.read_root_key())
190    }
191
192    /// Canonical identity of the same trust anchor used by this transport.
193    pub fn canonical_network_id(
194        &self,
195    ) -> Result<canic_core::ids::CanonicalNetworkId, OperatorMintTransportError> {
196        canic_core::ids::CanonicalNetworkId::from_der_root_trust_anchor(&self.agent.read_root_key())
197            .map_err(|_| OperatorMintTransportError::ReaderMismatch)
198    }
199
200    /// Selected signer, checked against the desired operator before reviewing.
201    pub fn operator(&self) -> Result<Principal, OperatorMintTransportError> {
202        self.agent
203            .get_principal()
204            .map_err(|_| OperatorMintTransportError::ReaderMismatch)
205    }
206
207    /// Submit one exact, already-approved ICP transfer argument.
208    pub async fn transfer(
209        &self,
210        intent: &OperatorMintIntentRecord,
211        argument: &[u8],
212    ) -> Result<Vec<u8>, OperatorMintTransportError> {
213        self.verify_reader(intent)?;
214        if argument != crate::fleet_ensure::ops::operator_mint::transfer_argument(intent)? {
215            return Err(OperatorMintTransportError::ReaderMismatch);
216        }
217        self.update(intent.authority.icp_ledger, "transfer", argument)
218            .await
219    }
220
221    /// Notify only for an authenticated ICP block and already-retained arguments.
222    pub async fn notify(
223        &self,
224        transfer: &VerifiedIcpTransfer,
225        argument: &[u8],
226    ) -> Result<Vec<u8>, OperatorMintTransportError> {
227        self.verify_reader(transfer.intent())?;
228        if argument
229            != crate::fleet_ensure::ops::operator_mint::notification_argument(
230                transfer.intent(),
231                transfer.block_index(),
232            )?
233        {
234            return Err(OperatorMintTransportError::ReaderMismatch);
235        }
236        self.update(
237            transfer.intent().authority.cmc,
238            "notify_mint_cycles",
239            argument,
240        )
241        .await
242    }
243
244    async fn update(
245        &self,
246        canister: Principal,
247        method: &str,
248        argument: &[u8],
249    ) -> Result<Vec<u8>, OperatorMintTransportError> {
250        Ok(tokio::time::timeout(
251            CALL_TIMEOUT,
252            self.agent
253                .update(&canister, method)
254                .with_arg(argument)
255                .call_and_wait(),
256        )
257        .await
258        .map_err(|_| OperatorMintTransportError::Timeout)??)
259    }
260
261    fn verify_reader(
262        &self,
263        intent: &OperatorMintIntentRecord,
264    ) -> Result<(), OperatorMintTransportError> {
265        if self.operator()? != intent.authority.operator
266            || self.network_identity() != intent.authority.network_identity_sha256
267        {
268            return Err(OperatorMintTransportError::ReaderMismatch);
269        }
270        Ok(())
271    }
272
273    /// Authenticate one ICP block with at most one Ledger-authorized archive hop.
274    pub async fn read_transfer(
275        &self,
276        intent: &OperatorMintIntentRecord,
277        block: u64,
278    ) -> Result<VerifiedIcpTransfer, OperatorMintTransportError> {
279        self.verify_reader(intent)?;
280        let read = icp::prepare_read(&self.agent, intent, block)?;
281        match self.read_icp(&read).await? {
282            IcpReadOutcome::Transfer(transfer) => Ok(transfer),
283            IcpReadOutcome::Archive(archive) => {
284                let read = icp::prepare_archive_read(&self.agent, &archive)?;
285                match self.read_icp(&read).await? {
286                    IcpReadOutcome::Transfer(transfer) => Ok(transfer),
287                    IcpReadOutcome::Archive(_) => {
288                        Err(OperatorMintTransportError::ReceiptUnavailable)
289                    }
290                }
291            }
292        }
293    }
294
295    async fn read_icp(
296        &self,
297        read: &IcpTransferRead,
298    ) -> Result<IcpReadOutcome, OperatorMintTransportError> {
299        tokio::time::timeout(CALL_TIMEOUT, async {
300            self.agent
301                .update_signed(read.canister(), read.signed_request().to_vec())
302                .await?;
303            loop {
304                let (status, certificate) = self
305                    .agent
306                    .request_status_raw(read.request_id(), read.canister())
307                    .await?;
308                match status {
309                    RequestStatusResponse::Replied(_) => {
310                        let mut bytes = Vec::new();
311                        ciborium::into_writer(&certificate, &mut bytes)
312                            .map_err(|_| OperatorMintTransportError::Serialization)?;
313                        return Ok(icp::verify_read(&self.agent, read, &bytes, ICP_LIMITS)?);
314                    }
315                    RequestStatusResponse::Rejected(_) | RequestStatusResponse::Done => {
316                        return Err(OperatorMintTransportError::ReceiptUnavailable);
317                    }
318                    _ => tokio::time::sleep(Duration::from_secs(1)).await,
319                }
320            }
321        })
322        .await
323        .map_err(|_| OperatorMintTransportError::Timeout)?
324    }
325
326    /// Fetch a bounded certified tip-to-deposit chain; incomplete history stays unresolved.
327    pub async fn read_deposit(
328        &self,
329        intent: &OperatorMintIntentRecord,
330        outcome: &OperatorMintNotificationOutcomeRecord,
331    ) -> Result<VerifiedCyclesDeposit, OperatorMintTransportError> {
332        self.verify_reader(intent)?;
333        let OperatorMintNotificationOutcomeRecord::Minted {
334            deposit_block_index,
335            ..
336        } = outcome
337        else {
338            return Err(OperatorMintTransportError::ReceiptUnavailable);
339        };
340        let ledger = intent.authority.cycles_ledger;
341        let evidence: Option<ICRC3DataCertificate> = self
342            .query(
343                ledger,
344                "icrc3_get_tip_certificate",
345                candid::encode_args(())?,
346            )
347            .await?;
348        let evidence = evidence.ok_or(OperatorMintTransportError::ReceiptUnavailable)?;
349        let tip = receipts::certified_tip_index(&self.agent, intent, &evidence, DEPOSIT_LIMITS)?;
350        let count = tip
351            .checked_sub(*deposit_block_index)
352            .and_then(|n| n.checked_add(1))
353            .filter(|count| *count <= DEPOSIT_LIMITS.blocks as u128)
354            .ok_or(OperatorMintTransportError::BudgetExceeded)?;
355        let reply: BlocksReply = self
356            .query(
357                ledger,
358                "icrc3_get_blocks",
359                candid::encode_one(vec![GetBlocksRequest {
360                    start: Nat::from(*deposit_block_index),
361                    length: Nat::from(count),
362                }])?,
363            )
364            .await?;
365        let mut blocks = reply.blocks;
366        blocks.sort_by(|a, b| b.id.cmp(&a.id));
367        Ok(receipts::verify_cycles_deposit(
368            &self.agent,
369            intent,
370            outcome,
371            &evidence,
372            &blocks,
373            DEPOSIT_LIMITS,
374        )?)
375    }
376
377    async fn query<T: CandidType + DeserializeOwned>(
378        &self,
379        canister: Principal,
380        method: &str,
381        args: Vec<u8>,
382    ) -> Result<T, OperatorMintTransportError> {
383        let bytes = tokio::time::timeout(
384            CALL_TIMEOUT,
385            self.agent.query(&canister, method).with_arg(args).call(),
386        )
387        .await
388        .map_err(|_| OperatorMintTransportError::Timeout)??;
389        let mut config = candid::de::DecoderConfig::new();
390        config
391            .set_decoding_quota(2_000_000)
392            .set_skipping_quota(100_000)
393            .set_max_type_len(10_000);
394        Ok(candid::utils::decode_one_with_config(&bytes, &config)?)
395    }
396}