1use {
4 crate::{
5 events::NexusEventKind,
6 move_bindings::{
7 interface::{payment::PaymentSourceKind, verifier::ToolVerifierSupport},
8 move_std::{ascii, type_name::TypeName},
9 registry::network_auth::IdentityKey,
10 sui_framework::{clock::Clock as SuiClock, linked_table::Node, object::ID},
11 tool::{
12 external_verifier::ExternalVerifier,
13 finite_credits,
14 invocation::Invocation,
15 time_pass,
16 tool_cashier::{CashierDeposit, PolicyKey, ToolCashier, ToolCashierInnerV1},
17 tool_registry::{
18 Tool as ToolAnchor,
19 ToolInnerV1,
20 ToolRegistry,
21 ToolRegistryInnerV1,
22 },
23 },
24 FiniteCredits,
25 TimePass,
26 },
27 move_boundary,
28 nexus::{
29 client::NexusClient,
30 error::NexusError,
31 registry::preflight_external_verifier_registration,
32 },
33 sui,
34 transactions::{tool, tool_cashier},
35 types::{NexusContext, PackageRole, ToolState},
36 ToolFqn,
37 },
38 std::{collections::HashSet, sync::Arc, time::Duration},
39};
40
41#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
43pub enum ToolCompatibility {
44 Current,
46 LegacyUnderstood,
48 MigrationRequired,
50 Unsupported,
52 Unavailable,
54}
55
56#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct EntitlementPurchaseResult {
59 pub tx_digest: sui::types::Digest,
60 pub entitlement_id: sui::types::Address,
61 pub deposit_id: sui::types::Address,
62}
63
64#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct EntitlementIssueResult {
67 pub tx_digest: sui::types::Digest,
68 pub entitlement_id: sui::types::Address,
69}
70
71#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
73pub struct FiniteCreditOffer {
74 pub issuance_enabled: bool,
75 pub price_per_credit: u64,
76 pub minimum_credits: u64,
77 pub maximum_credits: u64,
78}
79
80impl FiniteCreditOffer {
81 fn purchase_price(&self, credits: u64) -> Result<u64, NexusError> {
82 if !self.issuance_enabled {
83 return Err(NexusError::Configuration(
84 "Finite credit purchases are closed".to_owned(),
85 ));
86 }
87 if credits < self.minimum_credits || credits > self.maximum_credits {
88 return Err(NexusError::Configuration(format!(
89 "Credit count '{credits}' must be between '{}' and '{}'",
90 self.minimum_credits, self.maximum_credits,
91 )));
92 }
93 self.price_per_credit.checked_mul(credits).ok_or_else(|| {
94 NexusError::Configuration(
95 "Finite credit purchase price exceeds the maximum MIST value".to_owned(),
96 )
97 })
98 }
99}
100
101#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
103pub struct TimePassOffer {
104 pub issuance_enabled: bool,
105 pub price_per_ms: u64,
106 pub minimum_duration_ms: u64,
107 pub maximum_duration_ms: u64,
108}
109
110impl TimePassOffer {
111 fn purchase_price(&self, duration_ms: u64) -> Result<u64, NexusError> {
112 if !self.issuance_enabled {
113 return Err(NexusError::Configuration(
114 "Time pass purchases are closed".to_owned(),
115 ));
116 }
117 if duration_ms < self.minimum_duration_ms || duration_ms > self.maximum_duration_ms {
118 return Err(NexusError::Configuration(format!(
119 "Duration '{duration_ms}' must be between '{}' and '{}' milliseconds",
120 self.minimum_duration_ms, self.maximum_duration_ms,
121 )));
122 }
123 self.price_per_ms.checked_mul(duration_ms).ok_or_else(|| {
124 NexusError::Configuration(
125 "Time pass purchase price exceeds the maximum MIST value".to_owned(),
126 )
127 })
128 }
129}
130
131#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
137pub struct ToolEconomy {
138 pub tool_id: sui::types::Address,
139 pub cashier_id: sui::types::Address,
140 pub policies: Vec<TypeName>,
141 pub fixed_price_mist: u64,
142 pub finite_credits: Option<FiniteCreditOffer>,
143 pub time_pass: Option<TimePassOffer>,
144}
145
146#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
151pub struct FiniteCreditAccess {
152 pub account_id: sui::types::Address,
153 pub remaining: u64,
154 pub refunded_invocations: Vec<sui::types::Address>,
155}
156
157#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
162pub struct TimePassAccess {
163 pub account_id: sui::types::Address,
164 pub valid_from_ms: u64,
165 pub valid_until_ms: u64,
166 pub active: bool,
167}
168
169#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
174pub struct ToolAccess {
175 pub tool_id: sui::types::Address,
176 pub cashier_id: sui::types::Address,
177 pub beneficiary: PaymentSourceKind,
178 pub observed_at_ms: u64,
179 pub finite_credits: Option<FiniteCreditAccess>,
180 pub time_pass: Option<TimePassAccess>,
181}
182
183#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
185pub struct CollectableInvocation {
186 pub object_ref: sui::types::ObjectReference,
187 pub policy: TypeName,
188 pub sources: Vec<sui::types::Address>,
189 pub amount_mist: u64,
190}
191
192#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
194pub struct CollectableDeposit {
195 pub object_ref: sui::types::ObjectReference,
196 pub amount_mist: u64,
197}
198
199#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
201pub struct ToolCashierInbox {
202 pub invocations: Vec<CollectableInvocation>,
203 pub deposits: Vec<CollectableDeposit>,
204}
205
206#[derive(Clone, Debug)]
211pub struct ToolInspection {
212 pub fqn: ToolFqn,
214 pub tool_id: sui::types::Address,
216 pub tool_cashier_id: sui::types::Address,
218 pub exists: bool,
220 pub owner: Option<sui::types::Owner>,
222 pub witness_type: Option<sui::types::StructTag>,
224 pub inner_type: Option<sui::types::StructTag>,
226 pub compatibility: ToolCompatibility,
228 pub tool: Option<ToolState>,
230 pub verifier_support: Option<ToolVerifierSupport>,
232 pub external_verifier: Option<ExternalVerifier>,
234 pub timeout_ms: Option<u64>,
236 pub invocation_cost_mist: Option<u64>,
238 pub detail: Option<String>,
240}
241
242pub struct ToolActionResult {
244 pub tx_digest: sui::types::Digest,
246}
247
248pub type ToolCashierActionResult = ToolActionResult;
250
251pub struct ToolActions {
253 pub(super) client: NexusClient,
254}
255
256fn canonical_policy_accepted(
257 context: &NexusContext,
258 policies: &[TypeName],
259 module: &str,
260) -> Result<bool, NexusError> {
261 let origin = context
262 .type_origin(PackageRole::Tool, module, "Policy")
263 .map_err(NexusError::Parsing)?;
264 let expected = format!("{origin}::{module}::Policy");
265 Ok(policies
266 .iter()
267 .any(|policy| policy.matches_qualified_name(&expected)))
268}
269
270impl ToolActions {
271 async fn fetch_finite_credit_offer(
272 client: &NexusClient,
273 context: &NexusContext,
274 cashier_id: sui::types::Address,
275 tool_fqn: &ToolFqn,
276 ) -> Result<FiniteCreditOffer, NexusError> {
277 let config = client
278 .crawler()
279 .get_dynamic_field_by_key::<PolicyKey<finite_credits::Policy>, finite_credits::Config>(
280 cashier_id,
281 PolicyKey::new(false),
282 &crate::move_bindings::type_tag::<PolicyKey<finite_credits::Policy>>(context),
283 )
284 .await
285 .map_err(NexusError::Rpc)?
286 .ok_or_else(|| {
287 NexusError::Configuration(format!(
288 "Finite credits policy for Tool '{tool_fqn}' has no config"
289 ))
290 })?;
291 Ok(FiniteCreditOffer {
292 issuance_enabled: config.issuance_enabled,
293 price_per_credit: config.price_per_credit,
294 minimum_credits: config.minimum_credits,
295 maximum_credits: config.maximum_credits,
296 })
297 }
298
299 async fn fetch_time_pass_offer(
300 client: &NexusClient,
301 context: &NexusContext,
302 cashier_id: sui::types::Address,
303 tool_fqn: &ToolFqn,
304 ) -> Result<TimePassOffer, NexusError> {
305 let config = client
306 .crawler()
307 .get_dynamic_field_by_key::<PolicyKey<time_pass::Policy>, time_pass::Config>(
308 cashier_id,
309 PolicyKey::new(false),
310 &crate::move_bindings::type_tag::<PolicyKey<time_pass::Policy>>(context),
311 )
312 .await
313 .map_err(NexusError::Rpc)?
314 .ok_or_else(|| {
315 NexusError::Configuration(format!(
316 "Time pass policy for Tool '{tool_fqn}' has no config"
317 ))
318 })?;
319 Ok(TimePassOffer {
320 issuance_enabled: config.issuance_enabled,
321 price_per_ms: config.price_per_ms,
322 minimum_duration_ms: config.minimum_duration_ms,
323 maximum_duration_ms: config.maximum_duration_ms,
324 })
325 }
326
327 async fn resolve_tool_cashier_and_cap(
328 client: &NexusClient,
329 tool_fqn: &ToolFqn,
330 cashier_admin: sui::types::Address,
331 ) -> Result<
332 (
333 Arc<NexusContext>,
334 sui::types::ObjectReference,
335 sui::types::ObjectReference,
336 ),
337 NexusError,
338 > {
339 let tool_cashier = client.fetch_tool_cashier(tool_fqn).await?;
340 let context = client.context_for_object(*tool_cashier.object_id()).await?;
341 let cashier_admin = client
342 .crawler()
343 .get_object_metadata(cashier_admin)
344 .await
345 .map_err(|error| {
346 NexusError::Configuration(format!(
347 "Tool '{tool_fqn}' cashier admin capability '{cashier_admin}' could not be resolved: {error}"
348 ))
349 })?
350 .object_ref();
351 Ok((context, tool_cashier, cashier_admin))
352 }
353
354 pub async fn list_tools(&self) -> Result<Vec<ToolInspection>, NexusError> {
359 let (context, registry) = self.registry_state().await?;
360 let entries = self.tool_directory(&context, ®istry).await?;
361 let mut tools = Vec::with_capacity(entries.len());
362 for (fqn, tool_id) in entries {
363 tools.push(
364 self.inspect_tool_id(&context, ®istry, fqn, tool_id)
365 .await,
366 );
367 }
368 Ok(tools)
369 }
370
371 pub async fn inspect_tool(&self, fqn: &ToolFqn) -> Result<ToolInspection, NexusError> {
376 let (context, registry) = self.registry_state().await?;
377 let tool_id = crate::move_bindings::derive_tool_id(context.tool_registry.object_id(), fqn)
378 .map_err(NexusError::Parsing)?;
379 Ok(self
380 .inspect_tool_id(&context, ®istry, fqn.clone(), tool_id)
381 .await)
382 }
383
384 pub async fn update_timeout(
386 &self,
387 fqn: &ToolFqn,
388 timeout: Duration,
389 owner_cap: sui::types::Address,
390 ) -> Result<ToolActionResult, NexusError> {
391 let (context, tool_ref, owner_cap) = self.current_tool_inputs(fqn, owner_cap, true).await?;
392 let transaction = tool::update_tool_timeout_ptb(&context, &tool_ref, &owner_cap, timeout)
393 .map_err(NexusError::TransactionBuilding)?;
394 self.submit_action(transaction).await
395 }
396
397 pub async fn update_url(
399 &self,
400 fqn: &ToolFqn,
401 url: &str,
402 owner_cap: sui::types::Address,
403 ) -> Result<ToolActionResult, NexusError> {
404 let (context, tool_ref, owner_cap) =
405 self.current_tool_inputs(fqn, owner_cap, false).await?;
406 let transaction = tool::update_off_chain_tool_url_ptb(&context, &tool_ref, &owner_cap, url)
407 .map_err(NexusError::TransactionBuilding)?;
408 self.submit_action(transaction).await
409 }
410
411 pub async fn update_metadata(
413 &self,
414 fqn: &ToolFqn,
415 description: &str,
416 owner_cap: sui::types::Address,
417 ) -> Result<ToolActionResult, NexusError> {
418 let (context, tool_ref, owner_cap) =
419 self.current_tool_inputs(fqn, owner_cap, false).await?;
420 let transaction =
421 tool::update_tool_metadata_ptb(&context, &tool_ref, &owner_cap, description)
422 .map_err(NexusError::TransactionBuilding)?;
423 self.submit_action(transaction).await
424 }
425
426 pub async fn set_invocation_cost(
428 &self,
429 fqn: &ToolFqn,
430 cost_mist: u64,
431 cashier_admin: sui::types::Address,
432 ) -> Result<ToolActionResult, NexusError> {
433 let tool_ref = self.client.fetch_tool(fqn).await?;
434 let context = self
435 .client
436 .context_for_object_with_roots(
437 *tool_ref.object_id(),
438 std::slice::from_ref(&self.client.nexus_objects.tool_registry),
439 )
440 .await?;
441 let cashier_admin = self.client.object_reference(cashier_admin).await?;
442 let transaction =
443 tool::set_invocation_cost_ptb(&context, &tool_ref, &cashier_admin, cost_mist)
444 .map_err(NexusError::TransactionBuilding)?;
445 self.submit_action(transaction).await
446 }
447
448 pub async fn set_verified(
453 &self,
454 fqn: &ToolFqn,
455 verified: bool,
456 ) -> Result<ToolActionResult, NexusError> {
457 let tool_ref = self.client.fetch_tool(fqn).await?;
458 let objects = self.client.get_nexus_objects();
459 let context = self
460 .client
461 .context_for_object_with_roots(
462 *tool_ref.object_id(),
463 std::slice::from_ref(&objects.tool_registry),
464 )
465 .await?;
466 let admin = self
467 .client
468 .object_reference(objects.tool_registry_admin_cap.object_id())
469 .await?;
470 let transaction = tool::set_verified_ptb(&context, &tool_ref, &admin, verified)
471 .map_err(NexusError::TransactionBuilding)?;
472 self.submit_action(transaction).await
473 }
474
475 pub async fn configure_registered_key_verifier(
477 &self,
478 fqn: &ToolFqn,
479 owner_cap: sui::types::Address,
480 ) -> Result<ToolActionResult, NexusError> {
481 let tool_ref = self.client.fetch_tool(fqn).await?;
482 let tool_id = *tool_ref.object_id();
483 let binding_id = self
484 .client
485 .network_auth()
486 .binding_object_id(&IdentityKey::tool(tool_id))
487 .await?;
488 let objects = self.client.get_nexus_objects();
489 let context = self
490 .client
491 .context_for_object_with_roots(
492 objects.network_auth.object_id(),
493 std::slice::from_ref(&objects.tool_registry),
494 )
495 .await?;
496 let (owner_cap, tool_key_binding) = tokio::try_join!(
497 self.client.object_reference(owner_cap),
498 self.client.object_reference(binding_id),
499 )?;
500 let transaction = tool::configure_registered_key_verifier_ptb(
501 &context,
502 &tool_ref,
503 &owner_cap,
504 &tool_key_binding,
505 )
506 .map_err(NexusError::TransactionBuilding)?;
507 self.submit_action(transaction).await
508 }
509
510 pub async fn configure_external_verifier(
512 &self,
513 fqn: &ToolFqn,
514 owner_cap: sui::types::Address,
515 package_id: sui::types::Address,
516 module_name: &str,
517 function_name: &str,
518 verifier_object_ids: &[sui::types::Address],
519 ) -> Result<ToolActionResult, NexusError> {
520 let (context, tool_ref, owner_cap) = self.current_tool_inputs(fqn, owner_cap, true).await?;
521 let registration = preflight_external_verifier_registration(
522 self.client.crawler(),
523 &context,
524 package_id,
525 module_name,
526 function_name,
527 verifier_object_ids,
528 )
529 .await
530 .map_err(|error| NexusError::Configuration(error.to_string()))?;
531 let transaction =
532 tool::register_external_verifier_ptb(&context, &tool_ref, &owner_cap, ®istration)
533 .map_err(NexusError::TransactionBuilding)?;
534 self.submit_action(transaction).await
535 }
536
537 pub async fn migrate_on_chain_package(
539 &self,
540 fqn: &ToolFqn,
541 target_package: sui::types::Address,
542 owner_cap: sui::types::Address,
543 ) -> Result<ToolActionResult, NexusError> {
544 let (context, tool_ref, owner_cap) =
545 self.current_tool_inputs(fqn, owner_cap, false).await?;
546 let transaction = tool::migrate_on_chain_tool_package_ptb(
547 &context,
548 &tool_ref,
549 &owner_cap,
550 target_package,
551 )
552 .map_err(NexusError::TransactionBuilding)?;
553 self.submit_action(transaction).await
554 }
555
556 pub async fn unregister(
558 &self,
559 fqn: &ToolFqn,
560 owner_cap: sui::types::Address,
561 ) -> Result<ToolActionResult, NexusError> {
562 let (context, tool_ref, owner_cap) = self.unregister_inputs(fqn, owner_cap).await?;
563 let transaction = tool::unregister_ptb(&context, &tool_ref, &owner_cap)
564 .map_err(NexusError::TransactionBuilding)?;
565 self.submit_action(transaction).await
566 }
567
568 pub async fn claim_collateral(
570 &self,
571 fqn: &ToolFqn,
572 owner_cap: sui::types::Address,
573 ) -> Result<ToolActionResult, NexusError> {
574 let (context, tool_ref, owner_cap) =
575 self.current_tool_inputs(fqn, owner_cap, false).await?;
576 let transaction = tool::claim_collateral_for_self_ptb(&context, &tool_ref, &owner_cap)
577 .map_err(NexusError::TransactionBuilding)?;
578 self.submit_action(transaction).await
579 }
580
581 pub async fn enable_time_passes(
583 &self,
584 tool_fqn: &ToolFqn,
585 cashier_admin: sui::types::Address,
586 price_per_ms: u64,
587 minimum_duration_ms: u64,
588 maximum_duration_ms: u64,
589 ) -> Result<ToolCashierActionResult, NexusError> {
590 let client = &self.client;
591 let address = client.owner()?;
592 let (context, tool_cashier, cashier_admin) =
593 Self::resolve_tool_cashier_and_cap(client, tool_fqn, cashier_admin).await?;
594 let transaction = tool_cashier::enable_time_pass_ptb(
595 &context,
596 &tool_cashier,
597 &cashier_admin,
598 price_per_ms,
599 minimum_duration_ms,
600 maximum_duration_ms,
601 )
602 .map_err(NexusError::TransactionBuilding)?;
603 let response = client.submit_transaction(transaction, address).await?;
604 Ok(ToolCashierActionResult {
605 tx_digest: response.digest,
606 })
607 }
608
609 pub async fn close_time_pass_issuance(
611 &self,
612 fqn: &ToolFqn,
613 cashier_admin: sui::types::Address,
614 ) -> Result<ToolCashierActionResult, NexusError> {
615 let client = &self.client;
616 let address = client.owner()?;
617 let (context, tool_cashier, cashier_admin) =
618 Self::resolve_tool_cashier_and_cap(client, fqn, cashier_admin).await?;
619 let transaction =
620 tool_cashier::close_time_pass_issuance_ptb(&context, &tool_cashier, &cashier_admin)
621 .map_err(NexusError::TransactionBuilding)?;
622 let response = client.submit_transaction(transaction, address).await?;
623 Ok(ToolCashierActionResult {
624 tx_digest: response.digest,
625 })
626 }
627
628 pub async fn open_time_pass_issuance(
630 &self,
631 fqn: &ToolFqn,
632 cashier_admin: sui::types::Address,
633 ) -> Result<ToolCashierActionResult, NexusError> {
634 let client = &self.client;
635 let address = client.owner()?;
636 let (context, tool_cashier, cashier_admin) =
637 Self::resolve_tool_cashier_and_cap(client, fqn, cashier_admin).await?;
638 let transaction =
639 tool_cashier::open_time_pass_issuance_ptb(&context, &tool_cashier, &cashier_admin)
640 .map_err(NexusError::TransactionBuilding)?;
641 let response = client.submit_transaction(transaction, address).await?;
642 Ok(ToolCashierActionResult {
643 tx_digest: response.digest,
644 })
645 }
646
647 pub async fn update_time_pass_terms(
649 &self,
650 tool_fqn: &ToolFqn,
651 cashier_admin: sui::types::Address,
652 price_per_ms: u64,
653 minimum_duration_ms: u64,
654 maximum_duration_ms: u64,
655 ) -> Result<ToolCashierActionResult, NexusError> {
656 let client = &self.client;
657 let address = client.owner()?;
658 let (context, tool_cashier, cashier_admin) =
659 Self::resolve_tool_cashier_and_cap(client, tool_fqn, cashier_admin).await?;
660 let transaction = tool_cashier::update_time_pass_terms_ptb(
661 &context,
662 &tool_cashier,
663 &cashier_admin,
664 price_per_ms,
665 minimum_duration_ms,
666 maximum_duration_ms,
667 )
668 .map_err(NexusError::TransactionBuilding)?;
669 let response = client.submit_transaction(transaction, address).await?;
670 Ok(ToolCashierActionResult {
671 tx_digest: response.digest,
672 })
673 }
674
675 pub async fn buy_time_pass(
677 &self,
678 tool_fqn: &ToolFqn,
679 duration_ms: u64,
680 ) -> Result<EntitlementPurchaseResult, NexusError> {
681 let client = &self.client;
682 let beneficiary = PaymentSourceKind::user_funded(client.owner()?);
683 self.buy_time_pass_with(client, tool_fqn, duration_ms, beneficiary)
684 .await
685 }
686
687 pub async fn buy_time_pass_for(
689 &self,
690 tool_fqn: &ToolFqn,
691 duration_ms: u64,
692 beneficiary: PaymentSourceKind,
693 ) -> Result<EntitlementPurchaseResult, NexusError> {
694 let client = &self.client;
695 self.buy_time_pass_with(client, tool_fqn, duration_ms, beneficiary)
696 .await
697 }
698
699 async fn buy_time_pass_with(
700 &self,
701 client: &NexusClient,
702 tool_fqn: &ToolFqn,
703 duration_ms: u64,
704 beneficiary: PaymentSourceKind,
705 ) -> Result<EntitlementPurchaseResult, NexusError> {
706 if duration_ms == 0 {
707 return Err(NexusError::Configuration(
708 "Time pass duration must be greater than zero".to_owned(),
709 ));
710 }
711 let address = client.owner()?;
712 let tool_cashier = client.fetch_tool_cashier(tool_fqn).await?;
713 let context = client.context_for_object(*tool_cashier.object_id()).await?;
714 let price =
715 Self::fetch_time_pass_offer(client, &context, *tool_cashier.object_id(), tool_fqn)
716 .await?
717 .purchase_price(duration_ms)?;
718 let entitlement_id = crate::move_bindings::derive_time_pass_id(
719 &context,
720 *tool_cashier.object_id(),
721 beneficiary.clone(),
722 )
723 .map_err(NexusError::Parsing)?;
724 let pass = client
725 .crawler()
726 .get_optional_object::<TimePass>(entitlement_id)
727 .await
728 .map_err(NexusError::Rpc)?;
729 let account = match pass {
730 Some(pass) => {
731 if pass.data.cashier.bytes != *tool_cashier.object_id()
732 || pass.data.beneficiary != beneficiary
733 || !pass.is_shared()
734 {
735 return Err(NexusError::Configuration(format!(
736 "Time pass '{entitlement_id}' does not match its canonical account"
737 )));
738 }
739 Some(pass.object_ref())
740 }
741 None => None,
742 };
743 let transaction = tool_cashier::buy_time_pass_from_balance_ptb(
744 &context,
745 &tool_cashier,
746 account.as_ref(),
747 beneficiary,
748 duration_ms,
749 price,
750 )
751 .map_err(NexusError::TransactionBuilding)?;
752 let response = client.submit_transaction(transaction, address).await?;
753 let deposit_id = response
754 .events
755 .iter()
756 .find_map(|event| match &event.data {
757 NexusEventKind::CashierDepositCreated(created)
758 if created.cashier.bytes == *tool_cashier.object_id() =>
759 {
760 Some(created.deposit.bytes)
761 }
762 _ => None,
763 })
764 .ok_or_else(|| {
765 NexusError::Parsing(anyhow::anyhow!(
766 "Time pass purchase '{}' emitted no CashierDepositCreatedEvent",
767 response.digest
768 ))
769 })?;
770 Ok(EntitlementPurchaseResult {
771 tx_digest: response.digest,
772 entitlement_id,
773 deposit_id,
774 })
775 }
776
777 pub async fn issue_time_pass(
779 &self,
780 tool_fqn: &ToolFqn,
781 cashier_admin: sui::types::Address,
782 beneficiary: PaymentSourceKind,
783 valid_from_ms: u64,
784 valid_until_ms: u64,
785 ) -> Result<EntitlementIssueResult, NexusError> {
786 if valid_from_ms >= valid_until_ms {
787 return Err(NexusError::Configuration(
788 "Time pass end must be after its start".to_owned(),
789 ));
790 }
791 let client = &self.client;
792 let address = client.owner()?;
793 let (context, tool_cashier, cashier_admin) =
794 Self::resolve_tool_cashier_and_cap(client, tool_fqn, cashier_admin).await?;
795 let entitlement_id = crate::move_bindings::derive_time_pass_id(
796 &context,
797 *tool_cashier.object_id(),
798 beneficiary.clone(),
799 )
800 .map_err(NexusError::Parsing)?;
801 let pass = client
802 .crawler()
803 .get_optional_object::<TimePass>(entitlement_id)
804 .await
805 .map_err(NexusError::Rpc)?;
806 let transaction = match pass {
807 Some(pass) => {
808 if pass.data.cashier.bytes != *tool_cashier.object_id()
809 || pass.data.beneficiary != beneficiary
810 || !pass.is_shared()
811 {
812 return Err(NexusError::Configuration(format!(
813 "Time pass '{entitlement_id}' does not match its canonical account"
814 )));
815 }
816 tool_cashier::update_time_pass_window_ptb(
817 &context,
818 &tool_cashier,
819 &pass.object_ref(),
820 &cashier_admin,
821 valid_from_ms,
822 valid_until_ms,
823 )
824 }
825 None => tool_cashier::issue_time_pass_ptb(
826 &context,
827 &tool_cashier,
828 &cashier_admin,
829 beneficiary,
830 valid_from_ms,
831 valid_until_ms,
832 ),
833 }
834 .map_err(NexusError::TransactionBuilding)?;
835 let response = client.submit_transaction(transaction, address).await?;
836 Ok(EntitlementIssueResult {
837 tx_digest: response.digest,
838 entitlement_id,
839 })
840 }
841
842 pub async fn enable_finite_credits(
844 &self,
845 tool_fqn: &ToolFqn,
846 cashier_admin: sui::types::Address,
847 price_per_credit: u64,
848 minimum_credits: u64,
849 maximum_credits: u64,
850 ) -> Result<ToolCashierActionResult, NexusError> {
851 if minimum_credits == 0 {
852 return Err(NexusError::Configuration(
853 "Minimum credits must be at least one".to_owned(),
854 ));
855 }
856 if minimum_credits > maximum_credits {
857 return Err(NexusError::Configuration(format!(
858 "Minimum credits '{minimum_credits}' cannot exceed maximum credits '{maximum_credits}'"
859 )));
860 }
861 let client = &self.client;
862 let (context, tool_cashier, cashier_admin) =
863 Self::resolve_tool_cashier_and_cap(client, tool_fqn, cashier_admin).await?;
864 let transaction = tool_cashier::enable_finite_credits_ptb(
865 &context,
866 &tool_cashier,
867 &cashier_admin,
868 price_per_credit,
869 minimum_credits,
870 maximum_credits,
871 )
872 .map_err(NexusError::TransactionBuilding)?;
873 self.submit_action(transaction).await
874 }
875
876 pub async fn close_finite_credit_issuance(
878 &self,
879 fqn: &ToolFqn,
880 cashier_admin: sui::types::Address,
881 ) -> Result<ToolCashierActionResult, NexusError> {
882 let client = &self.client;
883 let address = client.owner()?;
884 let (context, tool_cashier, cashier_admin) =
885 Self::resolve_tool_cashier_and_cap(client, fqn, cashier_admin).await?;
886 let transaction =
887 tool_cashier::close_finite_credit_issuance_ptb(&context, &tool_cashier, &cashier_admin)
888 .map_err(NexusError::TransactionBuilding)?;
889 let response = client.submit_transaction(transaction, address).await?;
890 Ok(ToolCashierActionResult {
891 tx_digest: response.digest,
892 })
893 }
894
895 pub async fn open_finite_credit_issuance(
897 &self,
898 tool_fqn: &ToolFqn,
899 cashier_admin: sui::types::Address,
900 ) -> Result<ToolCashierActionResult, NexusError> {
901 let client = &self.client;
902 let address = client.owner()?;
903 let (context, tool_cashier, cashier_admin) =
904 Self::resolve_tool_cashier_and_cap(client, tool_fqn, cashier_admin).await?;
905 let transaction =
906 tool_cashier::open_finite_credit_issuance_ptb(&context, &tool_cashier, &cashier_admin)
907 .map_err(NexusError::TransactionBuilding)?;
908 let response = client.submit_transaction(transaction, address).await?;
909 Ok(ToolCashierActionResult {
910 tx_digest: response.digest,
911 })
912 }
913
914 pub async fn update_finite_credit_terms(
916 &self,
917 tool_fqn: &ToolFqn,
918 cashier_admin: sui::types::Address,
919 price_per_credit: u64,
920 minimum_credits: u64,
921 maximum_credits: u64,
922 ) -> Result<ToolCashierActionResult, NexusError> {
923 if minimum_credits == 0 {
924 return Err(NexusError::Configuration(
925 "Minimum credits must be at least one".to_owned(),
926 ));
927 }
928 if minimum_credits > maximum_credits {
929 return Err(NexusError::Configuration(format!(
930 "Minimum credits '{minimum_credits}' cannot exceed maximum credits '{maximum_credits}'"
931 )));
932 }
933 let client = &self.client;
934 let address = client.owner()?;
935 let (context, tool_cashier, cashier_admin) =
936 Self::resolve_tool_cashier_and_cap(client, tool_fqn, cashier_admin).await?;
937 let transaction = tool_cashier::update_finite_credit_terms_ptb(
938 &context,
939 &tool_cashier,
940 &cashier_admin,
941 price_per_credit,
942 minimum_credits,
943 maximum_credits,
944 )
945 .map_err(NexusError::TransactionBuilding)?;
946 let response = client.submit_transaction(transaction, address).await?;
947 Ok(ToolCashierActionResult {
948 tx_digest: response.digest,
949 })
950 }
951
952 pub async fn buy_finite_credits(
954 &self,
955 tool_fqn: &ToolFqn,
956 credits: u64,
957 ) -> Result<EntitlementPurchaseResult, NexusError> {
958 let client = &self.client;
959 let address = client.owner()?;
960 self.buy_finite_credits_with(
961 client,
962 tool_fqn,
963 credits,
964 PaymentSourceKind::user_funded(address),
965 )
966 .await
967 }
968
969 pub async fn buy_finite_credits_for(
971 &self,
972 tool_fqn: &ToolFqn,
973 credits: u64,
974 beneficiary: PaymentSourceKind,
975 ) -> Result<EntitlementPurchaseResult, NexusError> {
976 let client = &self.client;
977 self.buy_finite_credits_with(client, tool_fqn, credits, beneficiary)
978 .await
979 }
980
981 async fn buy_finite_credits_with(
982 &self,
983 client: &NexusClient,
984 tool_fqn: &ToolFqn,
985 credits: u64,
986 beneficiary: PaymentSourceKind,
987 ) -> Result<EntitlementPurchaseResult, NexusError> {
988 if credits == 0 {
989 return Err(NexusError::Configuration(
990 "Credits must be at least one".to_owned(),
991 ));
992 }
993 let address = client.owner()?;
994 let tool_cashier = client.fetch_tool_cashier(tool_fqn).await?;
995 let context = client.context_for_object(*tool_cashier.object_id()).await?;
996 let price =
997 Self::fetch_finite_credit_offer(client, &context, *tool_cashier.object_id(), tool_fqn)
998 .await?
999 .purchase_price(credits)?;
1000 let entitlement_id = crate::move_bindings::derive_finite_credits_id(
1001 &context,
1002 *tool_cashier.object_id(),
1003 beneficiary.clone(),
1004 )
1005 .map_err(NexusError::Parsing)?;
1006 let credit_account = client
1007 .crawler()
1008 .get_optional_object::<FiniteCredits>(entitlement_id)
1009 .await
1010 .map_err(NexusError::Rpc)?;
1011 let account = match credit_account {
1012 Some(account) => {
1013 if account.data.cashier.bytes != *tool_cashier.object_id()
1014 || account.data.beneficiary != beneficiary
1015 || !account.is_shared()
1016 {
1017 return Err(NexusError::Configuration(format!(
1018 "Finite credit account '{entitlement_id}' does not match its canonical account"
1019 )));
1020 }
1021 Some(account.object_ref())
1022 }
1023 None => None,
1024 };
1025 let transaction = tool_cashier::buy_finite_credits_from_balance_ptb(
1026 &context,
1027 &tool_cashier,
1028 account.as_ref(),
1029 beneficiary,
1030 credits,
1031 price,
1032 )
1033 .map_err(NexusError::TransactionBuilding)?;
1034 let response = client.submit_transaction(transaction, address).await?;
1035 let deposit_id = response
1036 .events
1037 .iter()
1038 .find_map(|event| match &event.data {
1039 NexusEventKind::CashierDepositCreated(created)
1040 if created.cashier.bytes == *tool_cashier.object_id() =>
1041 {
1042 Some(created.deposit.bytes)
1043 }
1044 _ => None,
1045 })
1046 .ok_or_else(|| {
1047 NexusError::Parsing(anyhow::anyhow!(
1048 "Finite credit purchase '{}' emitted no CashierDepositCreatedEvent",
1049 response.digest
1050 ))
1051 })?;
1052 Ok(EntitlementPurchaseResult {
1053 tx_digest: response.digest,
1054 entitlement_id,
1055 deposit_id,
1056 })
1057 }
1058
1059 pub async fn issue_finite_credits(
1061 &self,
1062 tool_fqn: &ToolFqn,
1063 cashier_admin: sui::types::Address,
1064 beneficiary: PaymentSourceKind,
1065 credits: u64,
1066 ) -> Result<EntitlementIssueResult, NexusError> {
1067 if credits == 0 {
1068 return Err(NexusError::Configuration(
1069 "Credits must be at least one".to_owned(),
1070 ));
1071 }
1072 let client = &self.client;
1073 let address = client.owner()?;
1074 let (context, tool_cashier, cashier_admin) =
1075 Self::resolve_tool_cashier_and_cap(client, tool_fqn, cashier_admin).await?;
1076 let entitlement_id = crate::move_bindings::derive_finite_credits_id(
1077 &context,
1078 *tool_cashier.object_id(),
1079 beneficiary.clone(),
1080 )
1081 .map_err(NexusError::Parsing)?;
1082 let credit_account = client
1083 .crawler()
1084 .get_optional_object::<FiniteCredits>(entitlement_id)
1085 .await
1086 .map_err(NexusError::Rpc)?;
1087 let transaction = match credit_account {
1088 Some(account) => {
1089 if account.data.cashier.bytes != *tool_cashier.object_id()
1090 || account.data.beneficiary != beneficiary
1091 || !account.is_shared()
1092 {
1093 return Err(NexusError::Configuration(format!(
1094 "Finite credit account '{entitlement_id}' does not match its canonical account"
1095 )));
1096 }
1097 tool_cashier::issue_more_finite_credits_ptb(
1098 &context,
1099 &tool_cashier,
1100 &account.object_ref(),
1101 &cashier_admin,
1102 credits,
1103 )
1104 }
1105 None => tool_cashier::issue_finite_credits_ptb(
1106 &context,
1107 &tool_cashier,
1108 &cashier_admin,
1109 beneficiary,
1110 credits,
1111 ),
1112 }
1113 .map_err(NexusError::TransactionBuilding)?;
1114 let response = client.submit_transaction(transaction, address).await?;
1115 Ok(EntitlementIssueResult {
1116 tx_digest: response.digest,
1117 entitlement_id,
1118 })
1119 }
1120
1121 pub async fn restore_finite_credit_refund(
1123 &self,
1124 tool_fqn: &ToolFqn,
1125 invocation_id: sui::types::Address,
1126 ) -> Result<EntitlementIssueResult, NexusError> {
1127 let client = &self.client;
1128 let sender = client.owner()?;
1129 let cashier = client.fetch_tool_cashier(tool_fqn).await?;
1130 let context = client.context_for_object(*cashier.object_id()).await?;
1131 let refunded = client
1132 .crawler()
1133 .get_object::<Invocation>(invocation_id)
1134 .await
1135 .map_err(NexusError::Rpc)?;
1136 let expected_policy =
1137 crate::transactions::invocation::InvocationPolicyCall::finite_credits_policy(&context)
1138 .map_err(NexusError::Parsing)?;
1139 if refunded.data.cashier_id.bytes != *cashier.object_id()
1140 || refunded.data.policy != expected_policy
1141 {
1142 return Err(NexusError::Configuration(format!(
1143 "Invocation '{invocation_id}' is not a finite credit refund for this Tool"
1144 )));
1145 }
1146 let [credits_source] = refunded.data.sources.as_slice() else {
1147 return Err(NexusError::Configuration(format!(
1148 "Invocation '{invocation_id}' does not name one finite credit account"
1149 )));
1150 };
1151 let credits_id = credits_source.bytes;
1152 if refunded.owner != sui::types::Owner::Address(credits_id)
1153 || refunded.data.refund_to.copied_option() != Some(credits_id)
1154 {
1155 return Err(NexusError::Configuration(format!(
1156 "Invocation '{invocation_id}' was not refunded to credit account '{credits_id}'"
1157 )));
1158 }
1159 let credits = client
1160 .crawler()
1161 .get_object::<FiniteCredits>(credits_id)
1162 .await
1163 .map_err(NexusError::Rpc)?;
1164 if credits.data.cashier.bytes != *cashier.object_id() || !credits.is_shared() {
1165 return Err(NexusError::Configuration(format!(
1166 "Finite credit account '{credits_id}' does not match this Tool"
1167 )));
1168 }
1169 let transaction = tool_cashier::restore_finite_credit_refund_ptb(
1170 &context,
1171 &credits.object_ref(),
1172 &refunded.object_ref(),
1173 )
1174 .map_err(NexusError::TransactionBuilding)?;
1175 let response = client.submit_transaction(transaction, sender).await?;
1176 Ok(EntitlementIssueResult {
1177 tx_digest: response.digest,
1178 entitlement_id: credits_id,
1179 })
1180 }
1181
1182 pub async fn inspect_access(
1188 &self,
1189 tool_fqn: &ToolFqn,
1190 beneficiary: PaymentSourceKind,
1191 ) -> Result<ToolAccess, NexusError> {
1192 let client = &self.client;
1193 let context = client
1194 .context_for_root(&client.nexus_objects.tool_registry)
1195 .await?;
1196 let crawler = client.crawler();
1197 let tool_id = crate::move_bindings::derive_tool_id(
1198 client.nexus_objects.tool_registry.object_id(),
1199 tool_fqn,
1200 )
1201 .map_err(NexusError::Parsing)?;
1202 let cashier_origin = context
1203 .type_origin(PackageRole::Tool, "tool_cashier", "ToolCashierKey")
1204 .map_err(NexusError::Parsing)?;
1205 let cashier_id = crate::move_bindings::derive_tool_cashier_id(cashier_origin, tool_id)
1206 .map_err(NexusError::Parsing)?;
1207 crawler
1208 .get_object_metadata(cashier_id)
1209 .await
1210 .map_err(NexusError::Rpc)?;
1211
1212 let observed_at_ms = crawler
1213 .get_object::<SuiClock>(move_boundary::CLOCK_OBJECT_ID)
1214 .await
1215 .map_err(NexusError::Rpc)?
1216 .data
1217 .timestamp_ms;
1218 let credits_id = crate::move_bindings::derive_finite_credits_id(
1219 &context,
1220 cashier_id,
1221 beneficiary.clone(),
1222 )
1223 .map_err(NexusError::Parsing)?;
1224 let pass_id =
1225 crate::move_bindings::derive_time_pass_id(&context, cashier_id, beneficiary.clone())
1226 .map_err(NexusError::Parsing)?;
1227
1228 let finite_credits = match crawler
1229 .get_optional_object::<FiniteCredits>(credits_id)
1230 .await
1231 .map_err(NexusError::Rpc)?
1232 {
1233 Some(credits) => {
1234 if !credits.is_shared()
1235 || credits.data.cashier.bytes != cashier_id
1236 || credits.data.beneficiary != beneficiary
1237 {
1238 return Err(NexusError::Configuration(format!(
1239 "Finite credit account '{credits_id}' does not match Tool '{tool_fqn}' and its beneficiary"
1240 )));
1241 }
1242 let policy =
1243 crate::transactions::invocation::InvocationPolicyCall::finite_credits_policy(
1244 &context,
1245 )
1246 .map_err(NexusError::Parsing)?;
1247 let mut refunded_invocations = crawler
1248 .get_owned_objects::<Invocation>(
1249 credits_id,
1250 crate::move_bindings::struct_tag::<Invocation>(&context),
1251 )
1252 .await
1253 .map_err(NexusError::Rpc)?
1254 .into_iter()
1255 .filter(|invocation| {
1256 invocation.data.cashier_id.bytes == cashier_id
1257 && invocation.data.policy == policy
1258 && invocation.data.sources.as_slice()
1259 == [crate::move_bindings::sui_framework::object::ID::new(
1260 credits_id,
1261 )]
1262 && invocation.data.refund_to.copied_option() == Some(credits_id)
1263 })
1264 .map(|invocation| invocation.object_id)
1265 .collect::<Vec<_>>();
1266 refunded_invocations.sort_unstable();
1267 Some(FiniteCreditAccess {
1268 account_id: credits_id,
1269 remaining: credits.data.state.remaining,
1270 refunded_invocations,
1271 })
1272 }
1273 None => None,
1274 };
1275
1276 let time_pass = match crawler
1277 .get_optional_object::<TimePass>(pass_id)
1278 .await
1279 .map_err(NexusError::Rpc)?
1280 {
1281 Some(pass) => {
1282 if !pass.is_shared()
1283 || pass.data.cashier.bytes != cashier_id
1284 || pass.data.beneficiary != beneficiary
1285 {
1286 return Err(NexusError::Configuration(format!(
1287 "Time pass account '{pass_id}' does not match Tool '{tool_fqn}' and its beneficiary"
1288 )));
1289 }
1290 let valid_from_ms = pass.data.state.valid_from_ms;
1291 let valid_until_ms = pass.data.state.valid_until_ms;
1292 Some(TimePassAccess {
1293 account_id: pass_id,
1294 valid_from_ms,
1295 valid_until_ms,
1296 active: observed_at_ms >= valid_from_ms && observed_at_ms < valid_until_ms,
1297 })
1298 }
1299 None => None,
1300 };
1301
1302 Ok(ToolAccess {
1303 tool_id,
1304 cashier_id,
1305 beneficiary,
1306 observed_at_ms,
1307 finite_credits,
1308 time_pass,
1309 })
1310 }
1311
1312 pub async fn inspect_economy(&self, tool_fqn: &ToolFqn) -> Result<ToolEconomy, NexusError> {
1317 let client = &self.client;
1318 Self::inspect_economy_with(client, tool_fqn).await
1319 }
1320
1321 async fn inspect_economy_with(
1322 client: &NexusClient,
1323 tool_fqn: &ToolFqn,
1324 ) -> Result<ToolEconomy, NexusError> {
1325 let crawler = client.crawler();
1326 let cashier_ref = client.fetch_tool_cashier(tool_fqn).await?;
1327 let cashier_id = *cashier_ref.object_id();
1328 let context = client.context_for_object(cashier_id).await?;
1329 let cashier = client
1330 .state_resolver()
1331 .load_inner_for_supported_witness::<ToolCashier, ToolCashierInnerV1>(
1332 cashier_id, &context,
1333 )
1334 .await?;
1335 let tool_id = crate::move_bindings::derive_tool_id(
1336 client.nexus_objects.tool_registry.object_id(),
1337 tool_fqn,
1338 )
1339 .map_err(NexusError::Parsing)?;
1340 if cashier.data.tool.bytes != tool_id
1341 || cashier.data.tool_fqn.as_str() != tool_fqn.to_string()
1342 {
1343 return Err(NexusError::Configuration(format!(
1344 "Tool cashier '{cashier_id}' does not describe Tool '{tool_fqn}'"
1345 )));
1346 }
1347
1348 let policies = cashier.data.policies.contents;
1349 if !canonical_policy_accepted(&context, &policies, "fixed_price")? {
1350 return Err(NexusError::Configuration(format!(
1351 "Tool '{tool_fqn}' is missing its mandatory fixed price policy"
1352 )));
1353 }
1354 let registry_root = &client.nexus_objects.tool_registry;
1355 let registry_context = client.context_for_root(registry_root).await?;
1356 let registry = client
1357 .state_resolver()
1358 .load_inner_for_supported_witness::<ToolRegistry, ToolRegistryInnerV1>(
1359 registry_root.object_id(),
1360 ®istry_context,
1361 )
1362 .await?;
1363 let fqn_key = ascii::String::from(tool_fqn.to_string());
1364 let fqn_type = crate::move_bindings::type_tag::<ascii::String>(®istry_context);
1365 let fixed_price_mist = crawler
1366 .get_dynamic_field_by_key::<ascii::String, u64>(
1367 registry.data.invocation_costs_mist.id(),
1368 fqn_key,
1369 &fqn_type,
1370 )
1371 .await
1372 .map_err(NexusError::Rpc)?
1373 .ok_or_else(|| {
1374 NexusError::Configuration(format!(
1375 "Fixed price policy for Tool '{tool_fqn}' has no price"
1376 ))
1377 })?;
1378 let finite_credits = if canonical_policy_accepted(&context, &policies, "finite_credits")? {
1379 Some(Self::fetch_finite_credit_offer(client, &context, cashier_id, tool_fqn).await?)
1380 } else {
1381 None
1382 };
1383 let time_pass = if canonical_policy_accepted(&context, &policies, "time_pass")? {
1384 Some(Self::fetch_time_pass_offer(client, &context, cashier_id, tool_fqn).await?)
1385 } else {
1386 None
1387 };
1388
1389 Ok(ToolEconomy {
1390 tool_id,
1391 cashier_id,
1392 policies,
1393 fixed_price_mist,
1394 finite_credits,
1395 time_pass,
1396 })
1397 }
1398
1399 pub async fn inspect_cashier_inbox(
1405 &self,
1406 tool_fqn: &ToolFqn,
1407 ) -> Result<ToolCashierInbox, NexusError> {
1408 let client = &self.client;
1409 let cashier = client.fetch_tool_cashier(tool_fqn).await?;
1410 let cashier_id = *cashier.object_id();
1411 let context = client.context_for_object(cashier_id).await?;
1412 let invocations = client
1413 .crawler()
1414 .get_owned_objects::<Invocation>(
1415 cashier_id,
1416 crate::move_bindings::struct_tag::<Invocation>(&context),
1417 )
1418 .await
1419 .map_err(NexusError::Rpc)?
1420 .into_iter()
1421 .map(|response| CollectableInvocation {
1422 object_ref: response.object_ref(),
1423 policy: response.data.policy,
1424 sources: response
1425 .data
1426 .sources
1427 .into_iter()
1428 .map(|source| source.bytes)
1429 .collect(),
1430 amount_mist: response.data.amount,
1431 })
1432 .collect();
1433 let deposits = client
1434 .crawler()
1435 .get_owned_objects::<CashierDeposit>(
1436 cashier_id,
1437 crate::move_bindings::struct_tag::<CashierDeposit>(&context),
1438 )
1439 .await
1440 .map_err(NexusError::Rpc)?
1441 .into_iter()
1442 .map(|response| CollectableDeposit {
1443 object_ref: response.object_ref(),
1444 amount_mist: response.data.funds.value,
1445 })
1446 .collect();
1447 Ok(ToolCashierInbox {
1448 invocations,
1449 deposits,
1450 })
1451 }
1452
1453 pub async fn collect_invocations(
1455 &self,
1456 tool_fqn: &ToolFqn,
1457 cashier_admin: sui::types::Address,
1458 invocation_ids: &[sui::types::Address],
1459 recipient: sui::types::Address,
1460 ) -> Result<ToolCashierActionResult, NexusError> {
1461 if invocation_ids.is_empty() {
1462 return Err(NexusError::Configuration(
1463 "Invocation collection requires at least one object ID".to_owned(),
1464 ));
1465 }
1466 let client = &self.client;
1467 let address = client.owner()?;
1468 let (context, cashier, admin) =
1469 Self::resolve_tool_cashier_and_cap(client, tool_fqn, cashier_admin).await?;
1470 let tool_id = crate::move_bindings::derive_tool_id(
1471 client.nexus_objects.tool_registry.object_id(),
1472 tool_fqn,
1473 )
1474 .map_err(NexusError::Parsing)?;
1475 let invocations = client
1476 .crawler()
1477 .get_objects::<Invocation>(invocation_ids)
1478 .await
1479 .map_err(NexusError::Rpc)?;
1480 let policy = invocations
1481 .first()
1482 .map(|invocation| invocation.data.policy.clone())
1483 .ok_or_else(|| {
1484 NexusError::Configuration(
1485 "Invocation collection requires at least one object".to_owned(),
1486 )
1487 })?;
1488 let references = invocations
1489 .into_iter()
1490 .map(|response| {
1491 if response.owner != sui::types::Owner::Address(*cashier.object_id())
1492 || response.data.cashier_id.bytes != *cashier.object_id()
1493 || response.data.tool_id.bytes != tool_id
1494 {
1495 return Err(NexusError::Configuration(format!(
1496 "Invocation '{}' is not in Tool cashier '{}'",
1497 response.object_id,
1498 cashier.object_id()
1499 )));
1500 }
1501 if response.data.policy != policy {
1502 return Err(NexusError::Configuration(format!(
1503 "Invocation '{}' uses policy '{}', not '{}'",
1504 response.object_id, response.data.policy, policy
1505 )));
1506 }
1507 Ok(response.object_ref())
1508 })
1509 .collect::<Result<Vec<_>, _>>()?;
1510 let transaction = tool_cashier::collect_invocations_ptb(
1511 &context,
1512 &cashier,
1513 &admin,
1514 &policy,
1515 &references,
1516 recipient,
1517 )
1518 .map_err(NexusError::TransactionBuilding)?;
1519 let response = client.submit_transaction(transaction, address).await?;
1520 Ok(ToolCashierActionResult {
1521 tx_digest: response.digest,
1522 })
1523 }
1524
1525 pub async fn collect_deposits(
1527 &self,
1528 tool_fqn: &ToolFqn,
1529 cashier_admin: sui::types::Address,
1530 deposit_ids: &[sui::types::Address],
1531 recipient: sui::types::Address,
1532 ) -> Result<ToolCashierActionResult, NexusError> {
1533 let client = &self.client;
1534 let (context, cashier, admin) =
1535 Self::resolve_tool_cashier_and_cap(client, tool_fqn, cashier_admin).await?;
1536 let references = client
1537 .crawler()
1538 .get_objects_metadata(deposit_ids)
1539 .await
1540 .map_err(NexusError::Rpc)?
1541 .into_iter()
1542 .map(|response| {
1543 if response.owner != sui::types::Owner::Address(*cashier.object_id()) {
1544 return Err(NexusError::Configuration(format!(
1545 "Deposit '{}' is not in Tool cashier '{}'",
1546 response.object_id,
1547 cashier.object_id()
1548 )));
1549 }
1550 Ok(response.object_ref())
1551 })
1552 .collect::<Result<Vec<_>, _>>()?;
1553 let transaction =
1554 tool_cashier::collect_deposits_ptb(&context, &cashier, &admin, &references, recipient)
1555 .map_err(NexusError::TransactionBuilding)?;
1556 self.submit_action(transaction).await
1557 }
1558
1559 async fn registry_state(&self) -> Result<(Arc<NexusContext>, ToolRegistryInnerV1), NexusError> {
1560 let root = &self.client.nexus_objects.tool_registry;
1561 let (snapshot, context) = self
1562 .client
1563 .state_resolver()
1564 .resolve_context_snapshot(self.client.get_nexus_objects(), root.object_id())
1565 .await?;
1566 let context = Arc::new(context);
1567 let state = self
1568 .client
1569 .state_resolver()
1570 .load_inner_for_supported_witness_from_snapshot::<ToolRegistry, ToolRegistryInnerV1>(
1571 &snapshot, &context,
1572 )?;
1573 Ok((context, state.data))
1574 }
1575
1576 async fn tool_directory(
1577 &self,
1578 context: &NexusContext,
1579 registry: &ToolRegistryInnerV1,
1580 ) -> Result<Vec<(ToolFqn, sui::types::Address)>, NexusError> {
1581 let mut next = registry.tool_ids.head.cloned_option();
1582 let mut seen = HashSet::with_capacity(registry.tool_ids.size());
1583 let mut entries = Vec::with_capacity(registry.tool_ids.size());
1584 let key_type = crate::move_bindings::type_tag::<ascii::String>(context);
1585
1586 while let Some(key) = next {
1587 let raw = std::str::from_utf8(&key.bytes).map_err(|error| {
1588 NexusError::Parsing(anyhow::anyhow!(
1589 "Tool Registry contains a non UTF8 name: {error}"
1590 ))
1591 })?;
1592 let fqn = raw.parse::<ToolFqn>().map_err(|error| {
1593 NexusError::Parsing(anyhow::anyhow!(
1594 "Tool Registry contains invalid FQN '{raw}': {error}"
1595 ))
1596 })?;
1597 if !seen.insert(key.clone()) {
1598 return Err(NexusError::InvalidObjectState {
1599 object: context.tool_registry.object_id(),
1600 reason: "Tool directory contains a cycle".to_owned(),
1601 });
1602 }
1603 let node = self
1604 .client
1605 .crawler()
1606 .get_dynamic_field_by_key::<ascii::String, Node<ascii::String, ID>>(
1607 registry.tool_ids.id(),
1608 key,
1609 &key_type,
1610 )
1611 .await
1612 .map_err(NexusError::Rpc)?
1613 .ok_or_else(|| NexusError::InvalidObjectState {
1614 object: context.tool_registry.object_id(),
1615 reason: format!("Tool directory node for '{fqn}' is missing"),
1616 })?;
1617 entries.push((fqn, node.value.bytes));
1618 next = node.next.into_option();
1619 }
1620
1621 if entries.len() != registry.tool_ids.size() {
1622 return Err(NexusError::InvalidObjectState {
1623 object: context.tool_registry.object_id(),
1624 reason: format!(
1625 "Tool directory reports {} entries but contains {}",
1626 registry.tool_ids.size(),
1627 entries.len()
1628 ),
1629 });
1630 }
1631 Ok(entries)
1632 }
1633
1634 async fn inspect_tool_id(
1635 &self,
1636 registry_context: &NexusContext,
1637 registry: &ToolRegistryInnerV1,
1638 fqn: ToolFqn,
1639 tool_id: sui::types::Address,
1640 ) -> ToolInspection {
1641 let current_package = registry_context.packages().get(PackageRole::Tool);
1642 let fallback_origin = current_package
1643 .and_then(|package| package.type_origin("tool_cashier", "ToolCashierKey").ok())
1644 .unwrap_or(sui::types::Address::ZERO);
1645 let mut inspection = ToolInspection {
1646 fqn,
1647 tool_id,
1648 tool_cashier_id: crate::move_bindings::derive_tool_cashier_id(fallback_origin, tool_id)
1649 .unwrap_or(sui::types::Address::ZERO),
1650 exists: false,
1651 owner: None,
1652 witness_type: None,
1653 inner_type: None,
1654 compatibility: ToolCompatibility::Unavailable,
1655 tool: None,
1656 verifier_support: None,
1657 external_verifier: None,
1658 timeout_ms: None,
1659 invocation_cost_mist: None,
1660 detail: None,
1661 };
1662
1663 match self
1664 .client
1665 .crawler()
1666 .get_optional_object::<ToolAnchor>(tool_id)
1667 .await
1668 {
1669 Ok(Some(anchor)) => {
1670 inspection.exists = true;
1671 inspection.owner = Some(anchor.owner);
1672 }
1673 Ok(None) => return inspection,
1674 Err(error) => {
1675 inspection.detail = Some(format!("Tool object is unavailable: {error}"));
1676 return inspection;
1677 }
1678 }
1679
1680 let snapshot = match self.client.state_resolver().observe_snapshot(tool_id).await {
1681 Ok(snapshot) => snapshot,
1682 Err(error) => {
1683 inspection.detail = Some(error.to_string());
1684 return inspection;
1685 }
1686 };
1687 let observed = snapshot.observed();
1688 inspection.owner = Some(observed.owner);
1689 inspection.witness_type = Some(observed.witness_type().clone());
1690 inspection.inner_type = Some(observed.inner_type().clone());
1691
1692 let packages = match self
1693 .client
1694 .state_resolver()
1695 .resolve_package_graph(observed)
1696 .await
1697 {
1698 Ok(packages) => packages,
1699 Err(error) => {
1700 inspection.compatibility = ToolCompatibility::Unsupported;
1701 inspection.detail = Some(error.to_string());
1702 return inspection;
1703 }
1704 };
1705 let context = NexusContext::new(self.client.get_nexus_objects(), packages);
1706 if let Ok(origin) = context
1707 .require_package(PackageRole::Tool)
1708 .and_then(|package| package.type_origin("tool_cashier", "ToolCashierKey"))
1709 {
1710 if let Ok(cashier_id) = crate::move_bindings::derive_tool_cashier_id(origin, tool_id) {
1711 inspection.tool_cashier_id = cashier_id;
1712 }
1713 }
1714
1715 let inner = match self
1716 .client
1717 .state_resolver()
1718 .load_inner_for_supported_witness_from_snapshot::<ToolAnchor, ToolInnerV1>(
1719 &snapshot, &context,
1720 ) {
1721 Ok(inner) => inner.data,
1722 Err(error) => {
1723 inspection.compatibility = ToolCompatibility::Unsupported;
1724 inspection.detail = Some(error.to_string());
1725 return inspection;
1726 }
1727 };
1728
1729 let registered = inner.unregistered_at_ms.vec.is_empty();
1730 let selected = context.packages().get(PackageRole::Tool);
1731 let is_current = current_package
1732 .zip(selected)
1733 .is_some_and(|(current, selected)| {
1734 current.storage_id == selected.storage_id && current.version == selected.version
1735 });
1736 inspection.compatibility = if is_current {
1737 ToolCompatibility::Current
1738 } else if registered {
1739 ToolCompatibility::MigrationRequired
1740 } else {
1741 ToolCompatibility::LegacyUnderstood
1742 };
1743 inspection.tool = Some(ToolState::new(tool_id, inner));
1744
1745 if registered {
1746 if let Err(error) = self
1747 .load_registry_projections(registry_context, registry, &mut inspection)
1748 .await
1749 {
1750 inspection.detail = Some(error.to_string());
1751 }
1752 }
1753 inspection
1754 }
1755
1756 async fn load_registry_projections(
1757 &self,
1758 context: &NexusContext,
1759 registry: &ToolRegistryInnerV1,
1760 inspection: &mut ToolInspection,
1761 ) -> Result<(), NexusError> {
1762 let fqn_key = ascii::String::from(inspection.fqn.to_string());
1763 let fqn_type = crate::move_bindings::type_tag::<ascii::String>(context);
1764 let id_type = crate::move_bindings::type_tag::<ID>(context);
1765 let crawler = self.client.crawler();
1766 let (timeout, invocation_cost, verifier_support, external_verifier) = tokio::try_join!(
1767 crawler.get_dynamic_field_by_key::<ascii::String, Node<ascii::String, u64>>(
1768 registry.timeouts.id(),
1769 fqn_key.clone(),
1770 &fqn_type,
1771 ),
1772 crawler.get_dynamic_field_by_key::<ascii::String, u64>(
1773 registry.invocation_costs_mist.id(),
1774 fqn_key,
1775 &fqn_type,
1776 ),
1777 crawler.get_dynamic_field_by_key::<ID, ToolVerifierSupport>(
1778 registry.verifier_support.id(),
1779 ID::new(inspection.tool_id),
1780 &id_type,
1781 ),
1782 crawler.get_dynamic_field_by_key::<ID, ExternalVerifier>(
1783 registry.external_verifiers.id(),
1784 ID::new(inspection.tool_id),
1785 &id_type,
1786 )
1787 )
1788 .map_err(NexusError::Rpc)?;
1789 inspection.timeout_ms = timeout.map(|node| node.value);
1790 inspection.invocation_cost_mist = invocation_cost;
1791 inspection.verifier_support = verifier_support;
1792 inspection.external_verifier = external_verifier;
1793 Ok(())
1794 }
1795
1796 async fn current_tool_inputs(
1797 &self,
1798 fqn: &ToolFqn,
1799 owner_cap: sui::types::Address,
1800 uses_registry: bool,
1801 ) -> Result<
1802 (
1803 Arc<NexusContext>,
1804 sui::types::ObjectReference,
1805 sui::types::ObjectReference,
1806 ),
1807 NexusError,
1808 > {
1809 let tool = self.client.fetch_tool(fqn).await?;
1810 let context = if uses_registry {
1811 self.client
1812 .context_for_object_with_roots(
1813 *tool.object_id(),
1814 std::slice::from_ref(&self.client.nexus_objects.tool_registry),
1815 )
1816 .await?
1817 } else {
1818 self.client.context_for_object(*tool.object_id()).await?
1819 };
1820 let owner_cap = self.client.object_reference(owner_cap).await?;
1821 Ok((context, tool, owner_cap))
1822 }
1823
1824 async fn unregister_inputs(
1832 &self,
1833 fqn: &ToolFqn,
1834 owner_cap: sui::types::Address,
1835 ) -> Result<
1836 (
1837 Arc<NexusContext>,
1838 sui::types::ObjectReference,
1839 sui::types::ObjectReference,
1840 ),
1841 NexusError,
1842 > {
1843 let registry = &self.client.nexus_objects.tool_registry;
1844 let context = self.client.context_for_root(registry).await?;
1845 let tool_id = crate::move_bindings::derive_tool_id(registry.object_id(), fqn)
1846 .map_err(NexusError::Parsing)?;
1847 let tool = self.client.object_reference(tool_id).await?;
1848 let owner_cap = self.client.object_reference(owner_cap).await?;
1849 Ok((context, tool, owner_cap))
1850 }
1851
1852 async fn submit_action(
1853 &self,
1854 transaction: sui::types::ProgrammableTransaction,
1855 ) -> Result<ToolActionResult, NexusError> {
1856 let response = self
1857 .client
1858 .submit_transaction(transaction, self.client.owner()?)
1859 .await?;
1860 Ok(ToolActionResult {
1861 tx_digest: response.digest,
1862 })
1863 }
1864}
1865
1866#[cfg(test)]
1867mod tests {
1868 use {
1869 super::*,
1870 crate::{
1871 move_bindings::{
1872 interface::meta_schema::MetaSchema,
1873 move_std::option::Option as MoveOption,
1874 sui_framework::{
1875 balance::Balance,
1876 linked_table::LinkedTable,
1877 object::UID,
1878 table::Table,
1879 vec_set::VecSet,
1880 },
1881 tool::{era::V1 as ToolWitnessV1, tool_registry::ToolRef},
1882 },
1883 test_utils::{nexus_mocks, sui_mocks},
1884 },
1885 std::collections::HashMap,
1886 };
1887
1888 fn registry_inner(
1889 directory_id: sui::types::Address,
1890 keys: &[ascii::String],
1891 ) -> ToolRegistryInnerV1 {
1892 let mut directory = LinkedTable::new(directory_id, keys.len() as u64);
1893 directory.head = MoveOption::from_option(keys.first().cloned());
1894 directory.tail = MoveOption::from_option(keys.last().cloned());
1895 ToolRegistryInnerV1::new(
1896 directory,
1897 Table::new(sui::types::Address::from_static("0xd2"), keys.len() as u64),
1898 Table::new(sui::types::Address::from_static("0xd3"), keys.len() as u64),
1899 LinkedTable::new(sui::types::Address::from_static("0xd4"), keys.len() as u64),
1900 Table::new(sui::types::Address::from_static("0xd5"), 0),
1901 Table::new(sui::types::Address::from_static("0xd6"), 0),
1902 Table::new(sui::types::Address::from_static("0xd7"), keys.len() as u64),
1903 LinkedTable::new(sui::types::Address::from_static("0xd8"), 0),
1904 LinkedTable::new(sui::types::Address::from_static("0xd9"), 0),
1905 0,
1906 0,
1907 )
1908 }
1909
1910 fn tool_inner(registry_id: sui::types::Address, fqn: &ToolFqn) -> ToolInnerV1 {
1911 ToolInnerV1::new(
1912 ID::new(registry_id),
1913 ascii::String::from(fqn.to_string()),
1914 ToolRef::Http {
1915 url: b"https://example.com/tool".to_vec(),
1916 },
1917 b"Compatibility fixture".to_vec(),
1918 MetaSchema::new(vec![], vec![]),
1919 false,
1920 Balance {
1921 value: 0,
1922 phantom_t0: std::marker::PhantomData,
1923 },
1924 false,
1925 0,
1926 0,
1927 MoveOption::from(Some(1)),
1928 )
1929 }
1930
1931 struct EconomyActionFixture {
1932 client: NexusClient,
1933 fqn: ToolFqn,
1934 cashier_admin: sui::types::Address,
1935 }
1936
1937 async fn economy_action_fixture_with<F>(configure: F) -> EconomyActionFixture
1938 where
1939 F: FnOnce(
1940 &NexusContext,
1941 sui::types::Address,
1942 &mut sui_mocks::grpc::MockLedgerService,
1943 &mut sui_mocks::grpc::MockStateService,
1944 ),
1945 {
1946 let context = sui_mocks::mock_nexus_context();
1947 let registry_id = context.tool_registry.object_id();
1948 let fqn = "xyz.taluslabs.economy.action@1"
1949 .parse::<ToolFqn>()
1950 .expect("Tool FQN parses");
1951 let tool_id =
1952 crate::move_bindings::derive_tool_id(registry_id, &fqn).expect("Tool ID derives");
1953 let cashier_origin = context
1954 .type_origin(PackageRole::Tool, "tool_cashier", "ToolCashierKey")
1955 .expect("cashier origin resolves");
1956 let cashier_id = crate::move_bindings::derive_tool_cashier_id(cashier_origin, tool_id)
1957 .expect("cashier ID derives");
1958 let cashier_admin = sui::types::Address::from_static("0xca");
1959 let policies = vec![
1960 crate::transactions::invocation::InvocationPolicyCall::fixed_price(&context)
1961 .expect("fixed price policy resolves")
1962 .policy,
1963 crate::transactions::invocation::InvocationPolicyCall::finite_credits_policy(&context)
1964 .expect("finite credit policy resolves"),
1965 crate::transactions::invocation::InvocationPolicyCall::time_pass(
1966 &context,
1967 sui::types::Address::ZERO,
1968 1,
1969 )
1970 .expect("time pass policy resolves")
1971 .policy,
1972 ];
1973 let mut ledger = sui_mocks::grpc::MockLedgerService::new();
1974 let mut packages = sui_mocks::grpc::MockMovePackageService::new();
1975 let mut state = sui_mocks::grpc::MockStateService::new();
1976 mock_registry(
1977 &mut ledger,
1978 &mut state,
1979 &context,
1980 registry_inner(sui::types::Address::from_static("0xd1"), &[]),
1981 );
1982 sui_mocks::grpc::mock_object_state::<ToolAnchor, ToolWitnessV1, ToolInnerV1>(
1983 &mut ledger,
1984 &mut state,
1985 &context,
1986 sui_mocks::object_ref_for_id(tool_id),
1987 sui::types::Owner::Shared(1),
1988 ToolAnchor::new(UID::new(tool_id)),
1989 tool_inner(registry_id, &fqn),
1990 );
1991 sui_mocks::grpc::mock_nexus_package_graph(&mut ledger, &mut packages, context.packages());
1992 sui_mocks::grpc::mock_object_state::<ToolCashier, ToolWitnessV1, ToolCashierInnerV1>(
1993 &mut ledger,
1994 &mut state,
1995 &context,
1996 sui_mocks::object_ref_for_id(cashier_id),
1997 sui::types::Owner::Shared(2),
1998 ToolCashier::new(UID::new(cashier_id)),
1999 ToolCashierInnerV1::new(
2000 ID::new(tool_id),
2001 ascii::String::from(fqn.to_string()),
2002 VecSet { contents: policies },
2003 ),
2004 );
2005 sui_mocks::grpc::mock_get_object_metadata_exact(
2006 &mut ledger,
2007 sui_mocks::object_ref_for_id(cashier_admin),
2008 sui::types::Owner::Address(sui::types::Address::from_static("0xa")),
2009 None,
2010 );
2011 configure(&context, cashier_id, &mut ledger, &mut state);
2012 let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
2013 ledger_service_mock: Some(ledger),
2014 package_service_mock: Some(packages),
2015 state_service_mock: Some(state),
2016 ..Default::default()
2017 });
2018 let client = nexus_mocks::mock_nexus_client_without_coins(&context, &rpc_url).await;
2019
2020 EconomyActionFixture {
2021 client,
2022 fqn,
2023 cashier_admin,
2024 }
2025 }
2026
2027 async fn economy_action_fixture() -> EconomyActionFixture {
2028 economy_action_fixture_with(|_, _, _, _| {}).await
2029 }
2030
2031 fn assert_missing_gas<T>(result: Result<T, NexusError>) {
2032 assert!(matches!(result, Err(NexusError::Configuration(message)) if
2033 message.contains("a gas source is required")));
2034 }
2035
2036 fn mock_registry(
2037 ledger_service: &mut sui_mocks::grpc::MockLedgerService,
2038 state_service: &mut sui_mocks::grpc::MockStateService,
2039 context: &NexusContext,
2040 registry: ToolRegistryInnerV1,
2041 ) {
2042 let registry_id = context.tool_registry.object_id();
2043 sui_mocks::grpc::mock_object_state::<ToolRegistry, ToolWitnessV1, ToolRegistryInnerV1>(
2044 ledger_service,
2045 state_service,
2046 context,
2047 sui_mocks::object_ref_for_id(registry_id),
2048 sui::types::Owner::Shared(context.tool_registry.initial_shared_version),
2049 ToolRegistry::new(UID::new(registry_id)),
2050 registry,
2051 );
2052 }
2053
2054 #[test]
2055 fn compatibility_states_are_distinct() {
2056 let states = [
2057 ToolCompatibility::Current,
2058 ToolCompatibility::LegacyUnderstood,
2059 ToolCompatibility::MigrationRequired,
2060 ToolCompatibility::Unsupported,
2061 ToolCompatibility::Unavailable,
2062 ];
2063 assert_eq!(states.into_iter().collect::<HashSet<_>>().len(), 5);
2064 }
2065
2066 #[test]
2067 fn canonical_offer_prices_enforce_sale_terms_and_overflow() {
2068 let finite = FiniteCreditOffer {
2069 issuance_enabled: true,
2070 price_per_credit: 3,
2071 minimum_credits: 2,
2072 maximum_credits: 4,
2073 };
2074 assert_eq!(finite.purchase_price(3).unwrap(), 9);
2075 assert!(finite.purchase_price(1).is_err());
2076 assert!(finite.purchase_price(5).is_err());
2077 assert!(FiniteCreditOffer {
2078 issuance_enabled: false,
2079 ..finite.clone()
2080 }
2081 .purchase_price(3)
2082 .is_err());
2083 assert!(FiniteCreditOffer {
2084 price_per_credit: u64::MAX,
2085 ..finite
2086 }
2087 .purchase_price(2)
2088 .is_err());
2089
2090 let pass = TimePassOffer {
2091 issuance_enabled: true,
2092 price_per_ms: 3,
2093 minimum_duration_ms: 2,
2094 maximum_duration_ms: 4,
2095 };
2096 assert_eq!(pass.purchase_price(3).unwrap(), 9);
2097 assert!(pass.purchase_price(1).is_err());
2098 assert!(pass.purchase_price(5).is_err());
2099 assert!(TimePassOffer {
2100 issuance_enabled: false,
2101 ..pass.clone()
2102 }
2103 .purchase_price(3)
2104 .is_err());
2105 assert!(TimePassOffer {
2106 price_per_ms: u64::MAX,
2107 ..pass
2108 }
2109 .purchase_price(2)
2110 .is_err());
2111 }
2112
2113 #[tokio::test]
2114 async fn policy_administration_reaches_submission_after_resolving_live_inputs() {
2115 let fixture = economy_action_fixture().await;
2116 assert_missing_gas(
2117 fixture
2118 .client
2119 .tool()
2120 .enable_time_passes(&fixture.fqn, fixture.cashier_admin, 2, 1, 10)
2121 .await,
2122 );
2123
2124 let fixture = economy_action_fixture().await;
2125 assert_missing_gas(
2126 fixture
2127 .client
2128 .tool()
2129 .close_time_pass_issuance(&fixture.fqn, fixture.cashier_admin)
2130 .await,
2131 );
2132
2133 let fixture = economy_action_fixture().await;
2134 assert_missing_gas(
2135 fixture
2136 .client
2137 .tool()
2138 .open_time_pass_issuance(&fixture.fqn, fixture.cashier_admin)
2139 .await,
2140 );
2141
2142 let fixture = economy_action_fixture().await;
2143 assert_missing_gas(
2144 fixture
2145 .client
2146 .tool()
2147 .update_time_pass_terms(&fixture.fqn, fixture.cashier_admin, 3, 2, 20)
2148 .await,
2149 );
2150
2151 let fixture = economy_action_fixture().await;
2152 assert_missing_gas(
2153 fixture
2154 .client
2155 .tool()
2156 .enable_finite_credits(&fixture.fqn, fixture.cashier_admin, 2, 1, 10)
2157 .await,
2158 );
2159
2160 let fixture = economy_action_fixture().await;
2161 assert_missing_gas(
2162 fixture
2163 .client
2164 .tool()
2165 .close_finite_credit_issuance(&fixture.fqn, fixture.cashier_admin)
2166 .await,
2167 );
2168
2169 let fixture = economy_action_fixture().await;
2170 assert_missing_gas(
2171 fixture
2172 .client
2173 .tool()
2174 .open_finite_credit_issuance(&fixture.fqn, fixture.cashier_admin)
2175 .await,
2176 );
2177
2178 let fixture = economy_action_fixture().await;
2179 assert_missing_gas(
2180 fixture
2181 .client
2182 .tool()
2183 .update_finite_credit_terms(&fixture.fqn, fixture.cashier_admin, 3, 2, 20)
2184 .await,
2185 );
2186 }
2187
2188 #[tokio::test]
2189 async fn economy_actions_reject_invalid_values_before_chain_reads() {
2190 let context = sui_mocks::mock_nexus_context();
2191 let rpc_url = sui_mocks::grpc::mock_server(Default::default());
2192 let client = nexus_mocks::mock_nexus_client_without_coins(&context, &rpc_url).await;
2193 let fqn = "xyz.taluslabs.validation@1"
2194 .parse::<ToolFqn>()
2195 .expect("Tool FQN parses");
2196 let admin = sui::types::Address::from_static("0xca");
2197 let beneficiary = PaymentSourceKind::user_funded(client.owner().unwrap());
2198
2199 assert!(client.tool().buy_time_pass(&fqn, 0).await.is_err());
2200 assert!(client.tool().buy_finite_credits(&fqn, 0).await.is_err());
2201 assert!(client
2202 .tool()
2203 .issue_time_pass(&fqn, admin, beneficiary.clone(), 10, 10)
2204 .await
2205 .is_err());
2206 assert!(client
2207 .tool()
2208 .issue_finite_credits(&fqn, admin, beneficiary, 0)
2209 .await
2210 .is_err());
2211 assert!(client
2212 .tool()
2213 .enable_finite_credits(&fqn, admin, 1, 0, 10)
2214 .await
2215 .is_err());
2216 assert!(client
2217 .tool()
2218 .enable_finite_credits(&fqn, admin, 1, 10, 1)
2219 .await
2220 .is_err());
2221 assert!(client
2222 .tool()
2223 .update_finite_credit_terms(&fqn, admin, 1, 0, 10)
2224 .await
2225 .is_err());
2226 assert!(client
2227 .tool()
2228 .update_finite_credit_terms(&fqn, admin, 1, 10, 1)
2229 .await
2230 .is_err());
2231 assert!(client
2232 .tool()
2233 .collect_invocations(&fqn, admin, &[], sui::types::Address::from_static("0xcb"),)
2234 .await
2235 .is_err());
2236 }
2237
2238 #[tokio::test]
2239 async fn entitlement_purchase_and_issuance_reach_submission_with_canonical_accounts() {
2240 let beneficiary = PaymentSourceKind::user_funded(sui::types::Address::from_static("0xb1"));
2241 let expected = beneficiary.clone();
2242 let fixture = economy_action_fixture_with(move |context, cashier_id, ledger, _state| {
2243 sui_mocks::grpc::mock_get_dynamic_field_by_key(
2244 ledger,
2245 cashier_id,
2246 &crate::move_bindings::type_tag::<PolicyKey<time_pass::Policy>>(context),
2247 PolicyKey::<time_pass::Policy>::new(false),
2248 time_pass::Config::new(true, 2, 1, 100),
2249 );
2250 let pass_id =
2251 crate::move_bindings::derive_time_pass_id(context, cashier_id, expected.clone())
2252 .expect("pass ID derives");
2253 sui_mocks::grpc::mock_get_object_not_found(ledger, pass_id);
2254 })
2255 .await;
2256 assert_missing_gas(
2257 fixture
2258 .client
2259 .tool()
2260 .buy_time_pass_for(&fixture.fqn, 10, beneficiary)
2261 .await,
2262 );
2263
2264 let beneficiary = PaymentSourceKind::user_funded(sui::types::Address::from_static("0xb2"));
2265 let expected = beneficiary.clone();
2266 let fixture = economy_action_fixture_with(move |context, cashier_id, ledger, _state| {
2267 sui_mocks::grpc::mock_get_dynamic_field_by_key(
2268 ledger,
2269 cashier_id,
2270 &crate::move_bindings::type_tag::<PolicyKey<finite_credits::Policy>>(context),
2271 PolicyKey::<finite_credits::Policy>::new(false),
2272 finite_credits::Config::new(true, 2, 1, 100),
2273 );
2274 let credits_id = crate::move_bindings::derive_finite_credits_id(
2275 context,
2276 cashier_id,
2277 expected.clone(),
2278 )
2279 .expect("credit ID derives");
2280 sui_mocks::grpc::mock_get_object_not_found(ledger, credits_id);
2281 })
2282 .await;
2283 assert_missing_gas(
2284 fixture
2285 .client
2286 .tool()
2287 .buy_finite_credits_for(&fixture.fqn, 10, beneficiary)
2288 .await,
2289 );
2290
2291 let beneficiary = PaymentSourceKind::user_funded(sui::types::Address::from_static("0xb3"));
2292 let expected = beneficiary.clone();
2293 let fixture = economy_action_fixture_with(move |context, cashier_id, ledger, _state| {
2294 let pass_id =
2295 crate::move_bindings::derive_time_pass_id(context, cashier_id, expected.clone())
2296 .expect("pass ID derives");
2297 sui_mocks::grpc::mock_get_object_not_found(ledger, pass_id);
2298 })
2299 .await;
2300 assert_missing_gas(
2301 fixture
2302 .client
2303 .tool()
2304 .issue_time_pass(&fixture.fqn, fixture.cashier_admin, beneficiary, 10, 20)
2305 .await,
2306 );
2307
2308 let beneficiary = PaymentSourceKind::user_funded(sui::types::Address::from_static("0xb4"));
2309 let expected = beneficiary.clone();
2310 let fixture = economy_action_fixture_with(move |context, cashier_id, ledger, _state| {
2311 let credits_id = crate::move_bindings::derive_finite_credits_id(
2312 context,
2313 cashier_id,
2314 expected.clone(),
2315 )
2316 .expect("credit ID derives");
2317 sui_mocks::grpc::mock_get_object_not_found(ledger, credits_id);
2318 })
2319 .await;
2320 assert_missing_gas(
2321 fixture
2322 .client
2323 .tool()
2324 .issue_finite_credits(&fixture.fqn, fixture.cashier_admin, beneficiary, 10)
2325 .await,
2326 );
2327 }
2328
2329 #[tokio::test]
2330 async fn existing_entitlements_are_extended_without_replacing_their_accounts() {
2331 let beneficiary = PaymentSourceKind::user_funded(sui::types::Address::from_static("0xb5"));
2332 let expected = beneficiary.clone();
2333 let fixture = economy_action_fixture_with(move |context, cashier_id, ledger, _state| {
2334 sui_mocks::grpc::mock_get_dynamic_field_by_key(
2335 ledger,
2336 cashier_id,
2337 &crate::move_bindings::type_tag::<PolicyKey<time_pass::Policy>>(context),
2338 PolicyKey::<time_pass::Policy>::new(false),
2339 time_pass::Config::new(true, 2, 1, 100),
2340 );
2341 let pass_id =
2342 crate::move_bindings::derive_time_pass_id(context, cashier_id, expected.clone())
2343 .expect("pass ID derives");
2344 let pass = TimePass::new(
2345 UID::new(pass_id),
2346 ID::new(cashier_id),
2347 expected,
2348 time_pass::State::new(10, 20),
2349 );
2350 sui_mocks::grpc::mock_get_object_bcs(
2351 ledger,
2352 sui_mocks::object_ref_for_id(pass_id),
2353 sui::types::Owner::Shared(3),
2354 bcs::to_bytes(&pass).expect("pass serializes"),
2355 );
2356 })
2357 .await;
2358 assert_missing_gas(
2359 fixture
2360 .client
2361 .tool()
2362 .buy_time_pass_for(&fixture.fqn, 10, beneficiary)
2363 .await,
2364 );
2365
2366 let beneficiary = PaymentSourceKind::user_funded(sui::types::Address::from_static("0xb6"));
2367 let expected = beneficiary.clone();
2368 let fixture = economy_action_fixture_with(move |context, cashier_id, ledger, _state| {
2369 sui_mocks::grpc::mock_get_dynamic_field_by_key(
2370 ledger,
2371 cashier_id,
2372 &crate::move_bindings::type_tag::<PolicyKey<finite_credits::Policy>>(context),
2373 PolicyKey::<finite_credits::Policy>::new(false),
2374 finite_credits::Config::new(true, 2, 1, 100),
2375 );
2376 let credits_id = crate::move_bindings::derive_finite_credits_id(
2377 context,
2378 cashier_id,
2379 expected.clone(),
2380 )
2381 .expect("credit ID derives");
2382 let credits = FiniteCredits::new(
2383 UID::new(credits_id),
2384 ID::new(cashier_id),
2385 expected,
2386 finite_credits::State::new(7),
2387 );
2388 sui_mocks::grpc::mock_get_object_bcs(
2389 ledger,
2390 sui_mocks::object_ref_for_id(credits_id),
2391 sui::types::Owner::Shared(4),
2392 bcs::to_bytes(&credits).expect("credits serialize"),
2393 );
2394 })
2395 .await;
2396 assert_missing_gas(
2397 fixture
2398 .client
2399 .tool()
2400 .buy_finite_credits_for(&fixture.fqn, 10, beneficiary)
2401 .await,
2402 );
2403
2404 let beneficiary = PaymentSourceKind::user_funded(sui::types::Address::from_static("0xb7"));
2405 let expected = beneficiary.clone();
2406 let fixture = economy_action_fixture_with(move |context, cashier_id, ledger, _state| {
2407 let pass_id =
2408 crate::move_bindings::derive_time_pass_id(context, cashier_id, expected.clone())
2409 .expect("pass ID derives");
2410 let pass = TimePass::new(
2411 UID::new(pass_id),
2412 ID::new(cashier_id),
2413 expected,
2414 time_pass::State::new(10, 20),
2415 );
2416 sui_mocks::grpc::mock_get_object_bcs(
2417 ledger,
2418 sui_mocks::object_ref_for_id(pass_id),
2419 sui::types::Owner::Shared(5),
2420 bcs::to_bytes(&pass).expect("pass serializes"),
2421 );
2422 })
2423 .await;
2424 assert_missing_gas(
2425 fixture
2426 .client
2427 .tool()
2428 .issue_time_pass(&fixture.fqn, fixture.cashier_admin, beneficiary, 20, 30)
2429 .await,
2430 );
2431
2432 let beneficiary = PaymentSourceKind::user_funded(sui::types::Address::from_static("0xb8"));
2433 let expected = beneficiary.clone();
2434 let fixture = economy_action_fixture_with(move |context, cashier_id, ledger, _state| {
2435 let credits_id = crate::move_bindings::derive_finite_credits_id(
2436 context,
2437 cashier_id,
2438 expected.clone(),
2439 )
2440 .expect("credit ID derives");
2441 let credits = FiniteCredits::new(
2442 UID::new(credits_id),
2443 ID::new(cashier_id),
2444 expected,
2445 finite_credits::State::new(7),
2446 );
2447 sui_mocks::grpc::mock_get_object_bcs(
2448 ledger,
2449 sui_mocks::object_ref_for_id(credits_id),
2450 sui::types::Owner::Shared(6),
2451 bcs::to_bytes(&credits).expect("credits serialize"),
2452 );
2453 })
2454 .await;
2455 assert_missing_gas(
2456 fixture
2457 .client
2458 .tool()
2459 .issue_finite_credits(&fixture.fqn, fixture.cashier_admin, beneficiary, 10)
2460 .await,
2461 );
2462 }
2463
2464 #[tokio::test]
2465 async fn finite_credit_refund_restoration_uses_the_exact_recorded_account() {
2466 let invocation_id = sui::types::Address::from_static("0x91");
2467 let credits_id = sui::types::Address::from_static("0x92");
2468 let beneficiary = PaymentSourceKind::user_funded(sui::types::Address::from_static("0xb9"));
2469 let fixture = economy_action_fixture_with(move |context, cashier_id, ledger, _state| {
2470 let policy =
2471 crate::transactions::invocation::InvocationPolicyCall::finite_credits_policy(
2472 context,
2473 )
2474 .expect("finite credit policy resolves");
2475 let invocation = Invocation::new(
2476 UID::new(invocation_id),
2477 sui::types::Address::from_static("0xe1"),
2478 b"vertex".to_vec(),
2479 ID::new(sui::types::Address::from_static("0xe2")),
2480 ID::new(cashier_id),
2481 beneficiary.clone(),
2482 policy,
2483 vec![ID::new(credits_id)],
2484 0,
2485 MoveOption::from_option(Some(credits_id)),
2486 Balance {
2487 value: 0,
2488 phantom_t0: std::marker::PhantomData,
2489 },
2490 );
2491 let credits = FiniteCredits::new(
2492 UID::new(credits_id),
2493 ID::new(cashier_id),
2494 beneficiary,
2495 finite_credits::State::new(0),
2496 );
2497 sui_mocks::grpc::mock_get_object_bcs(
2498 ledger,
2499 sui_mocks::object_ref_for_id(invocation_id),
2500 sui::types::Owner::Address(credits_id),
2501 bcs::to_bytes(&invocation).expect("Invocation serializes"),
2502 );
2503 sui_mocks::grpc::mock_get_object_bcs(
2504 ledger,
2505 sui_mocks::object_ref_for_id(credits_id),
2506 sui::types::Owner::Shared(7),
2507 bcs::to_bytes(&credits).expect("credits serialize"),
2508 );
2509 })
2510 .await;
2511
2512 assert_missing_gas(
2513 fixture
2514 .client
2515 .tool()
2516 .restore_finite_credit_refund(&fixture.fqn, invocation_id)
2517 .await,
2518 );
2519 }
2520
2521 #[tokio::test]
2522 async fn economy_inspection_decodes_every_canonical_offer() {
2523 let fixture = economy_action_fixture_with(|context, cashier_id, ledger, _state| {
2524 let fqn = "xyz.taluslabs.economy.action@1"
2525 .parse::<ToolFqn>()
2526 .expect("Tool FQN parses");
2527 let fqn_key = ascii::String::from(fqn.to_string());
2528 sui_mocks::grpc::mock_get_dynamic_field_by_key(
2529 ledger,
2530 sui::types::Address::from_static("0xd7"),
2531 &crate::move_bindings::type_tag::<ascii::String>(context),
2532 fqn_key,
2533 17_u64,
2534 );
2535 sui_mocks::grpc::mock_get_dynamic_field_by_key(
2536 ledger,
2537 cashier_id,
2538 &crate::move_bindings::type_tag::<PolicyKey<finite_credits::Policy>>(context),
2539 PolicyKey::<finite_credits::Policy>::new(false),
2540 finite_credits::Config::new(true, 2, 1, 100),
2541 );
2542 sui_mocks::grpc::mock_get_dynamic_field_by_key(
2543 ledger,
2544 cashier_id,
2545 &crate::move_bindings::type_tag::<PolicyKey<time_pass::Policy>>(context),
2546 PolicyKey::<time_pass::Policy>::new(false),
2547 time_pass::Config::new(true, 3, 4, 200),
2548 );
2549 })
2550 .await;
2551
2552 let economy = fixture
2553 .client
2554 .tool()
2555 .inspect_economy(&fixture.fqn)
2556 .await
2557 .expect("economy inspection succeeds");
2558
2559 assert_eq!(economy.fixed_price_mist, 17);
2560 assert_eq!(
2561 economy.finite_credits,
2562 Some(FiniteCreditOffer {
2563 issuance_enabled: true,
2564 price_per_credit: 2,
2565 minimum_credits: 1,
2566 maximum_credits: 100,
2567 })
2568 );
2569 assert_eq!(
2570 economy.time_pass,
2571 Some(TimePassOffer {
2572 issuance_enabled: true,
2573 price_per_ms: 3,
2574 minimum_duration_ms: 4,
2575 maximum_duration_ms: 200,
2576 })
2577 );
2578 }
2579
2580 #[tokio::test]
2581 async fn empty_cashier_inbox_is_discovered_without_a_mutable_index() {
2582 let fixture = economy_action_fixture_with(|_, _, _ledger, state| {
2583 state.expect_list_owned_objects().times(2).returning(|_| {
2584 Ok(tonic::Response::new(
2585 sui::grpc::ListOwnedObjectsResponse::default(),
2586 ))
2587 });
2588 })
2589 .await;
2590
2591 let inbox = fixture
2592 .client
2593 .tool()
2594 .inspect_cashier_inbox(&fixture.fqn)
2595 .await
2596 .expect("cashier inbox inspection succeeds");
2597
2598 assert!(inbox.invocations.is_empty());
2599 assert!(inbox.deposits.is_empty());
2600 }
2601
2602 #[tokio::test]
2603 async fn owner_collection_validates_exact_cashier_objects_before_submission() {
2604 let invocation_id = sui::types::Address::from_static("0x93");
2605 let fixture = economy_action_fixture_with(move |context, cashier_id, ledger, _state| {
2606 let fqn = "xyz.taluslabs.economy.action@1"
2607 .parse::<ToolFqn>()
2608 .expect("Tool FQN parses");
2609 let tool_id =
2610 crate::move_bindings::derive_tool_id(context.tool_registry.object_id(), &fqn)
2611 .expect("Tool ID derives");
2612 let policy =
2613 crate::transactions::invocation::InvocationPolicyCall::fixed_price(context)
2614 .expect("fixed price policy resolves")
2615 .policy;
2616 let invocation = Invocation::new(
2617 UID::new(invocation_id),
2618 sui::types::Address::from_static("0xe1"),
2619 b"vertex".to_vec(),
2620 ID::new(tool_id),
2621 ID::new(cashier_id),
2622 PaymentSourceKind::user_funded(sui::types::Address::from_static("0xba")),
2623 policy,
2624 vec![ID::new(sui::types::Address::from_static("0xe3"))],
2625 9,
2626 MoveOption::from_option(None),
2627 Balance {
2628 value: 9,
2629 phantom_t0: std::marker::PhantomData,
2630 },
2631 );
2632 sui_mocks::grpc::mock_get_objects_bcs(
2633 ledger,
2634 vec![(
2635 sui_mocks::object_ref_for_id(invocation_id),
2636 sui::types::Owner::Address(cashier_id),
2637 bcs::to_bytes(&invocation).expect("Invocation serializes"),
2638 crate::move_bindings::struct_tag::<Invocation>(context),
2639 )],
2640 );
2641 })
2642 .await;
2643 assert_missing_gas(
2644 fixture
2645 .client
2646 .tool()
2647 .collect_invocations(
2648 &fixture.fqn,
2649 fixture.cashier_admin,
2650 &[invocation_id],
2651 sui::types::Address::from_static("0xbb"),
2652 )
2653 .await,
2654 );
2655
2656 let deposit_id = sui::types::Address::from_static("0x94");
2657 let fixture = economy_action_fixture_with(move |_, cashier_id, ledger, _state| {
2658 sui_mocks::grpc::mock_get_objects_metadata(
2659 ledger,
2660 vec![(
2661 sui_mocks::object_ref_for_id(deposit_id),
2662 sui::types::Owner::Address(cashier_id),
2663 None,
2664 )],
2665 );
2666 })
2667 .await;
2668 assert_missing_gas(
2669 fixture
2670 .client
2671 .tool()
2672 .collect_deposits(
2673 &fixture.fqn,
2674 fixture.cashier_admin,
2675 &[deposit_id],
2676 sui::types::Address::from_static("0xbb"),
2677 )
2678 .await,
2679 );
2680 }
2681
2682 #[tokio::test]
2683 async fn inspect_tool_reports_missing_when_neither_object_exists() {
2684 let context = sui_mocks::mock_nexus_context();
2685 let fqn = "xyz.taluslabs.missing.tool@1".parse::<ToolFqn>().unwrap();
2686 let tool_id =
2687 crate::move_bindings::derive_tool_id(context.tool_registry.object_id(), &fqn).unwrap();
2688 let mut ledger_service = sui_mocks::grpc::MockLedgerService::new();
2689 let mut package_service = sui_mocks::grpc::MockMovePackageService::new();
2690 let mut state_service = sui_mocks::grpc::MockStateService::new();
2691 mock_registry(
2692 &mut ledger_service,
2693 &mut state_service,
2694 &context,
2695 registry_inner(sui::types::Address::from_static("0xd1"), &[]),
2696 );
2697 sui_mocks::grpc::mock_get_object_not_found(&mut ledger_service, tool_id);
2698 sui_mocks::grpc::mock_package_versions(
2699 &mut ledger_service,
2700 &mut package_service,
2701 context.packages().all().cloned(),
2702 );
2703 let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
2704 ledger_service_mock: Some(ledger_service),
2705 package_service_mock: Some(package_service),
2706 state_service_mock: Some(state_service),
2707 ..Default::default()
2708 });
2709 let client = nexus_mocks::mock_nexus_client_without_coins(&context, &rpc_url).await;
2710
2711 let inspection = client.tool().inspect_tool(&fqn).await.unwrap();
2712
2713 assert!(!inspection.exists);
2714 assert!(inspection.tool.is_none());
2715 assert_eq!(inspection.tool_id, tool_id);
2716 }
2717
2718 #[tokio::test]
2719 async fn mixed_tool_versions_are_classified_without_aborting_the_inventory() {
2720 let context = sui_mocks::mock_nexus_context();
2721 let registry_id = context.tool_registry.object_id();
2722 let directory_id = sui::types::Address::from_static("0xd1");
2723 let entries = [
2724 (
2725 "xyz.taluslabs.current.tool@1".parse::<ToolFqn>().unwrap(),
2726 sui::types::Address::from_static("0xc1"),
2727 ),
2728 (
2729 "xyz.taluslabs.unavailable.tool@1"
2730 .parse::<ToolFqn>()
2731 .unwrap(),
2732 sui::types::Address::from_static("0xf1"),
2733 ),
2734 ];
2735 let keys = entries
2736 .iter()
2737 .map(|(fqn, _)| ascii::String::from(fqn.to_string()))
2738 .collect::<Vec<_>>();
2739 let mut ledger_service = sui_mocks::grpc::MockLedgerService::new();
2740 let mut package_service = sui_mocks::grpc::MockMovePackageService::new();
2741 let mut state_service = sui_mocks::grpc::MockStateService::new();
2742 mock_registry(
2743 &mut ledger_service,
2744 &mut state_service,
2745 &context,
2746 registry_inner(directory_id, &keys),
2747 );
2748 let key_type = crate::move_bindings::type_tag::<ascii::String>(&context);
2749 for (index, ((_, tool_id), key)) in entries.iter().zip(&keys).enumerate() {
2750 sui_mocks::grpc::mock_get_dynamic_field_by_key(
2751 &mut ledger_service,
2752 directory_id,
2753 &key_type,
2754 key.clone(),
2755 Node::<ascii::String, ID>::new(
2756 MoveOption::from_option(index.checked_sub(1).map(|i| keys[i].clone())),
2757 MoveOption::from_option(keys.get(index + 1).cloned()),
2758 ID::new(*tool_id),
2759 ),
2760 );
2761 }
2762 let current_ref = sui_mocks::object_ref_for_id(entries[0].1);
2763 sui_mocks::grpc::mock_object_state::<ToolAnchor, ToolWitnessV1, ToolInnerV1>(
2764 &mut ledger_service,
2765 &mut state_service,
2766 &context,
2767 current_ref,
2768 sui::types::Owner::Shared(1),
2769 ToolAnchor::new(UID::new(entries[0].1)),
2770 tool_inner(registry_id, &entries[0].0),
2771 );
2772 sui_mocks::grpc::mock_get_object_not_found(&mut ledger_service, entries[1].1);
2773 sui_mocks::grpc::mock_package_versions(
2774 &mut ledger_service,
2775 &mut package_service,
2776 context.packages().all().cloned(),
2777 );
2778 let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
2779 ledger_service_mock: Some(ledger_service),
2780 package_service_mock: Some(package_service),
2781 state_service_mock: Some(state_service),
2782 ..Default::default()
2783 });
2784 let client = nexus_mocks::mock_nexus_client_without_coins(&context, &rpc_url).await;
2785
2786 let inspections = client.tool().list_tools().await.unwrap();
2787 let by_fqn = inspections
2788 .iter()
2789 .map(|inspection| (inspection.fqn.to_string(), inspection))
2790 .collect::<HashMap<_, _>>();
2791
2792 assert_eq!(inspections.len(), 2);
2793 assert_eq!(
2794 by_fqn["xyz.taluslabs.current.tool@1"].compatibility,
2795 ToolCompatibility::Current
2796 );
2797 assert!(by_fqn["xyz.taluslabs.current.tool@1"].tool.is_some());
2798 assert_eq!(
2799 by_fqn["xyz.taluslabs.unavailable.tool@1"].compatibility,
2800 ToolCompatibility::Unavailable
2801 );
2802 assert!(by_fqn["xyz.taluslabs.unavailable.tool@1"].tool.is_none());
2803 }
2804
2805 #[tokio::test]
2806 async fn access_inspection_derives_and_reads_canonical_accounts() {
2807 let context = sui_mocks::mock_nexus_context();
2808 let tool_fqn = "xyz.taluslabs.access@1"
2809 .parse::<ToolFqn>()
2810 .expect("Tool FQN parses");
2811 let tool_id =
2812 crate::move_bindings::derive_tool_id(context.tool_registry.object_id(), &tool_fqn)
2813 .expect("tool id derives");
2814 let cashier_origin = context
2815 .type_origin(PackageRole::Tool, "tool_cashier", "ToolCashierKey")
2816 .expect("cashier origin resolves");
2817 let cashier_id = crate::move_bindings::derive_tool_cashier_id(cashier_origin, tool_id)
2818 .expect("cashier id derives");
2819 let beneficiary = PaymentSourceKind::user_funded(sui::types::Address::from_static("0x31"));
2820 let credits_id = crate::move_bindings::derive_finite_credits_id(
2821 &context,
2822 cashier_id,
2823 beneficiary.clone(),
2824 )
2825 .expect("credit account id derives");
2826 let pass_id =
2827 crate::move_bindings::derive_time_pass_id(&context, cashier_id, beneficiary.clone())
2828 .expect("time pass id derives");
2829 let credits = FiniteCredits::new(
2830 UID::new(credits_id),
2831 ID::new(cashier_id),
2832 beneficiary.clone(),
2833 finite_credits::State::new(7),
2834 );
2835 let pass = TimePass::new(
2836 UID::new(pass_id),
2837 ID::new(cashier_id),
2838 beneficiary.clone(),
2839 time_pass::State::new(10, 90),
2840 );
2841
2842 let mut ledger = sui_mocks::grpc::MockLedgerService::new();
2843 let mut packages = sui_mocks::grpc::MockMovePackageService::new();
2844 let mut state = sui_mocks::grpc::MockStateService::new();
2845 mock_registry(
2846 &mut ledger,
2847 &mut state,
2848 &context,
2849 registry_inner(sui::types::Address::from_static("0xd1"), &[]),
2850 );
2851 sui_mocks::grpc::mock_nexus_package_graph(&mut ledger, &mut packages, context.packages());
2852 sui_mocks::grpc::mock_get_object_metadata(
2853 &mut ledger,
2854 sui_mocks::object_ref_for_id(cashier_id),
2855 sui::types::Owner::Shared(1),
2856 None,
2857 );
2858 sui_mocks::grpc::mock_get_object_bcs(
2859 &mut ledger,
2860 sui_mocks::object_ref_for_id(move_boundary::CLOCK_OBJECT_ID),
2861 sui::types::Owner::Shared(1),
2862 bcs::to_bytes(&SuiClock::new(move_boundary::CLOCK_OBJECT_ID, 50))
2863 .expect("clock serializes"),
2864 );
2865 sui_mocks::grpc::mock_get_object_bcs(
2866 &mut ledger,
2867 sui_mocks::object_ref_for_id(credits_id),
2868 sui::types::Owner::Shared(4),
2869 bcs::to_bytes(&credits).expect("credits serialize"),
2870 );
2871 sui_mocks::grpc::mock_get_object_bcs(
2872 &mut ledger,
2873 sui_mocks::object_ref_for_id(pass_id),
2874 sui::types::Owner::Shared(5),
2875 bcs::to_bytes(&pass).expect("pass serializes"),
2876 );
2877 state
2878 .expect_list_owned_objects()
2879 .times(1)
2880 .return_once(move |request| {
2881 assert_eq!(
2882 request.get_ref().owner.as_deref(),
2883 Some(credits_id.to_string().as_str())
2884 );
2885 Ok(tonic::Response::new(
2886 sui::grpc::ListOwnedObjectsResponse::default(),
2887 ))
2888 });
2889 let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
2890 ledger_service_mock: Some(ledger),
2891 package_service_mock: Some(packages),
2892 state_service_mock: Some(state),
2893 ..Default::default()
2894 });
2895 let client = nexus_mocks::mock_nexus_client_without_coins(&context, &rpc_url).await;
2896
2897 let access = client
2898 .tool()
2899 .inspect_access(&tool_fqn, beneficiary.clone())
2900 .await
2901 .expect("access inspection succeeds");
2902
2903 assert_eq!(access.tool_id, tool_id);
2904 assert_eq!(access.cashier_id, cashier_id);
2905 assert_eq!(access.beneficiary, beneficiary);
2906 assert_eq!(access.observed_at_ms, 50);
2907 assert_eq!(
2908 access.finite_credits,
2909 Some(FiniteCreditAccess {
2910 account_id: credits_id,
2911 remaining: 7,
2912 refunded_invocations: vec![],
2913 })
2914 );
2915 assert_eq!(
2916 access.time_pass,
2917 Some(TimePassAccess {
2918 account_id: pass_id,
2919 valid_from_ms: 10,
2920 valid_until_ms: 90,
2921 active: true,
2922 })
2923 );
2924 }
2925
2926 #[tokio::test]
2927 async fn unregister_inputs_do_not_decode_tool_state() {
2928 let context = sui_mocks::mock_nexus_context();
2929 let fqn = "xyz.taluslabs.legacy.tool@1".parse::<ToolFqn>().unwrap();
2930 let tool_id =
2931 crate::move_bindings::derive_tool_id(context.tool_registry.object_id(), &fqn).unwrap();
2932 let tool_ref = sui_mocks::object_ref_for_id(tool_id);
2933 let owner_cap_ref = sui_mocks::object_ref_for_id(sui::types::Address::from_static("0xc4"));
2934 let mut ledger_service = sui_mocks::grpc::MockLedgerService::new();
2935 let mut package_service = sui_mocks::grpc::MockMovePackageService::new();
2936 let mut state_service = sui_mocks::grpc::MockStateService::new();
2937 mock_registry(
2938 &mut ledger_service,
2939 &mut state_service,
2940 &context,
2941 registry_inner(sui::types::Address::from_static("0xd1"), &[]),
2942 );
2943 sui_mocks::grpc::mock_nexus_package_graph(
2944 &mut ledger_service,
2945 &mut package_service,
2946 context.packages(),
2947 );
2948 sui_mocks::grpc::mock_get_object_metadata(
2949 &mut ledger_service,
2950 tool_ref.clone(),
2951 sui::types::Owner::Shared(1),
2952 None,
2953 );
2954 sui_mocks::grpc::mock_get_object_metadata(
2955 &mut ledger_service,
2956 owner_cap_ref.clone(),
2957 sui::types::Owner::Address(sui::types::Address::from_static("0xa")),
2958 None,
2959 );
2960 let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
2961 ledger_service_mock: Some(ledger_service),
2962 package_service_mock: Some(package_service),
2963 state_service_mock: Some(state_service),
2964 ..Default::default()
2965 });
2966 let client = nexus_mocks::mock_nexus_client_without_coins(&context, &rpc_url).await;
2967
2968 let (selected, selected_tool, selected_cap) = client
2969 .tool()
2970 .unregister_inputs(&fqn, *owner_cap_ref.object_id())
2971 .await
2972 .unwrap();
2973
2974 assert_eq!(selected_tool, tool_ref);
2975 assert_eq!(selected_cap, owner_cap_ref);
2976 assert_eq!(
2977 selected.packages().get(PackageRole::Tool),
2978 context.packages().get(PackageRole::Tool),
2979 );
2980 }
2981}