Skip to main content

nexus_sdk/nexus/
tool.rs

1//! Tool inspection, owner configuration, and cashier operations.
2
3use {
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/// Compatibility of one Tool with this SDK and the current Registry authority.
42#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
43pub enum ToolCompatibility {
44    /// The Tool selects the same package graph as the current Registry.
45    Current,
46    /// The SDK understands the Tool, and no live Registry mutation is required.
47    LegacyUnderstood,
48    /// The SDK understands the Tool, but live Registry use requires migration.
49    MigrationRequired,
50    /// The observed era and inner pair has no adapter in this SDK.
51    Unsupported,
52    /// The Tool could not be observed or decoded independently.
53    Unavailable,
54}
55
56/// Confirmed entitlement purchase with both discoverable object IDs.
57#[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/// Confirmed entitlement state change with its canonical object ID.
65#[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/// Current sale terms for canonical finite Tool invocation credits.
72#[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/// Current sale terms for canonical time based Tool access.
102#[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/// Discoverable economic policies accepted by one [`ToolCashier`].
132///
133/// [`ToolEconomy::policies`] preserves every owner supplied policy type.
134/// Canonical offers are decoded into stable SDK values for direct use by
135/// clients and command line tools.
136#[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/// User facing finite credit state for one Tool and beneficiary.
147///
148/// This combines the shared account with refunded child [Invocation] objects,
149/// which no single Move value exposes as one serializable view.
150#[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/// User facing time pass state for one Tool and beneficiary.
158///
159/// The active flag is evaluated against the onchain clock used by admission,
160/// so clients do not need to compare local time with Move state.
161#[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/// Complete user facing access for one Tool and payment beneficiary.
170///
171/// The account IDs are derived from canonical Move keys. This stable view
172/// combines those identities with typed state for RPC, CLI, and index users.
173#[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/// One finalized Invocation waiting in a [`ToolCashier`] inbox.
184#[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/// One prepaid sale deposit waiting in a [`ToolCashier`] inbox.
193#[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/// Indexed economic objects currently owned by one [`ToolCashier`].
200#[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/// Result of [`ToolActions::inspect_tool`].
207///
208/// The object IDs are derived locally even when the Tool does not exist.
209/// An existing Tool includes its complete [`ToolState`] record.
210#[derive(Clone, Debug)]
211pub struct ToolInspection {
212    /// Requested fully qualified name.
213    pub fqn: ToolFqn,
214    /// Stable Tool object ID.
215    pub tool_id: sui::types::Address,
216    /// Stable cashier object ID.
217    pub tool_cashier_id: sui::types::Address,
218    /// Whether the stable Tool object exists.
219    pub exists: bool,
220    /// Current owner when the Tool was observed.
221    pub owner: Option<sui::types::Owner>,
222    /// Exact era type observed below the Tool anchor.
223    pub witness_type: Option<sui::types::StructTag>,
224    /// Exact inner type observed below the Tool anchor.
225    pub inner_type: Option<sui::types::StructTag>,
226    /// Compatibility classification isolated to this Tool.
227    pub compatibility: ToolCompatibility,
228    /// Complete supported Tool state.
229    pub tool: Option<ToolState>,
230    /// Current Registry verifier support while the Tool is registered.
231    pub verifier_support: Option<ToolVerifierSupport>,
232    /// Current external verifier record, when configured.
233    pub external_verifier: Option<ExternalVerifier>,
234    /// Current Registry timeout while the Tool is registered.
235    pub timeout_ms: Option<u64>,
236    /// Current invocation price while the Tool is registered.
237    pub invocation_cost_mist: Option<u64>,
238    /// Diagnostic detail for unsupported or unavailable state.
239    pub detail: Option<String>,
240}
241
242/// Result of a Tool owner or cashier transaction.
243pub struct ToolActionResult {
244    /// Digest of the submitted transaction.
245    pub tx_digest: sui::types::Digest,
246}
247
248/// Compatibility preserving alias for cashier callers.
249pub type ToolCashierActionResult = ToolActionResult;
250
251/// Operations over Tool state, Registry projections, and payment state.
252pub 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    /// Lists every currently registered Tool.
355    ///
356    /// A Tool that cannot be decoded remains in the result with its own
357    /// compatibility status. One bad Tool cannot hide the rest of the Registry.
358    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, &registry).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, &registry, fqn, tool_id)
365                    .await,
366            );
367        }
368        Ok(tools)
369    }
370
371    /// Inspects one stable Tool ID derived from its FQN.
372    ///
373    /// Missing, unsupported, and unavailable Tool state is represented in the
374    /// returned value so callers can make compatibility decisions explicitly.
375    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, &registry, fqn.clone(), tool_id)
381            .await)
382    }
383
384    /// Updates the execution timeout for a registered Tool.
385    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    /// Updates the HTTP endpoint for a registered off chain Tool.
398    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    /// Updates the description for a registered Tool.
412    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    /// Updates the invocation price for a registered Tool.
427    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    /// Changes the Registry verification status of a registered Tool.
449    ///
450    /// The configured [`crate::types::NexusObjects::tool_registry_admin_cap`]
451    /// must be owned by this client's signer.
452    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    /// Enables registered key verification for a registered off chain Tool.
476    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    /// Validates and installs one external verifier for an off chain Tool.
511    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, &registration)
533                .map_err(NexusError::TransactionBuilding)?;
534        self.submit_action(transaction).await
535    }
536
537    /// Points a registered on chain Tool at a new package.
538    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    /// Unregisters a Tool from every live Registry lookup.
557    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    /// Claims unlocked US collateral after Tool unregistration.
569    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    /// Enables time pass sales and admission for a [`crate::types::Tool`].
582    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    /// Closes time pass issuance for a [`crate::types::Tool`] without invalidating existing passes.
610    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    /// Opens time pass issuance for a [`crate::types::Tool`] using its current terms.
629    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    /// Updates time pass terms for a [`crate::types::Tool`] without invalidating existing passes.
648    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    /// Buys time access for the signer beneficiary from its SUI address balance.
676    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    /// Buys time access for an explicit beneficiary from the signer address balance.
688    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    /// Sets the canonical time pass window under Tool owner authority.
778    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    /// Enables finite credit sales and admission for a [`crate::types::Tool`].
843    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    /// Closes finite credit issuance for a [`crate::types::Tool`] without invalidating existing credits.
877    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    /// Opens finite credit issuance for a [`crate::types::Tool`] using its current terms.
896    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    /// Updates finite credit terms for a [`crate::types::Tool`] without invalidating issued credits.
915    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    /// Buys finite credits for the signer from its SUI address balance.
953    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    /// Buys finite credits for an explicit beneficiary from the signer address balance.
970    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    /// Issues shared finite credits under Tool owner authority.
1060    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    /// Restores one refunded Invocation to its exact finite credit account.
1122    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    /// Reads the canonical access accounts for one Tool and beneficiary.
1183    ///
1184    /// Account IDs are derived from the Tool cashier, beneficiary, and policy.
1185    /// The returned finite credit refunds are exact [Invocation] objects that
1186    /// can be passed to [`ToolActions::restore_finite_credit_refund`].
1187    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    /// Reads every accepted policy and the canonical offers for one [`crate::types::Tool`].
1313    ///
1314    /// Custom policy witness types remain visible in [`ToolEconomy::policies`]
1315    /// even when this SDK does not know how to decode their private configs.
1316    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                &registry_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>(&registry_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    /// Lists finalized Invocations and prepaid deposits waiting for collection.
1400    ///
1401    /// Transfer to Object indexes these objects under the [`ToolCashier`] ID as
1402    /// an address owner. Discovery therefore needs neither a mutable on chain
1403    /// registry nor a global usage counter.
1404    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    /// Collects one same policy batch of finalized Invocations.
1454    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    /// Collects one batch of prepaid sale deposits.
1526    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    /// Resolves [`Self::unregister`] from stable IDs and current Registry authority.
1825    ///
1826    /// Unregistration is the recovery path for a Tool whose inner value this
1827    /// SDK cannot decode. The transaction therefore reads only object metadata
1828    /// for the Tool and owner capability. The current [`ToolRegistry`] selects
1829    /// the call target, and the Move transition remains authoritative for the
1830    /// Tool state and ownership checks.
1831    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}