1use 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
39const 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#[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
102pub struct OperatorMintTransport {
108 agent: Agent,
109}
110
111#[derive(CandidType, Deserialize)]
112struct BlocksReply {
113 blocks: Vec<CyclesLedgerBlock>,
114}
115
116impl OperatorMintTransport {
117 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 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 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 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 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 #[must_use]
188 pub fn network_identity(&self) -> [u8; 32] {
189 receipts::network_identity_sha256(&self.agent.read_root_key())
190 }
191
192 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 pub fn operator(&self) -> Result<Principal, OperatorMintTransportError> {
202 self.agent
203 .get_principal()
204 .map_err(|_| OperatorMintTransportError::ReaderMismatch)
205 }
206
207 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 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 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 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}