Skip to main content

nexus_sdk/transactions/
tool.rs

1use {
2    crate::{
3        move_bindings::{
4            registry::registered_key_verifier as registered_key_verifier_binding,
5            sui_framework::transfer as transfer_binding,
6            tool::tool_registry as tool_registry_binding,
7        },
8        move_boundary,
9        sui,
10        types::{NexusContext, OnchainToolMode, PackageRole, ToolMeta},
11        ToolFqn,
12    },
13    anyhow::{bail, Context as _},
14    std::{collections::HashSet, time::Duration},
15    sui::types::{Argument, ProgrammableTransaction},
16};
17
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct ExternalVerifierObjectInput {
20    /// Current shared object reference.
21    pub object_ref: sui::types::ObjectReference,
22    /// Exact concrete Move type required by the verifier ABI.
23    pub object_type: sui::types::TypeTag,
24}
25
26/// Validated external verifier definition used to configure a Tool.
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct ExternalVerifierRegistrationInput {
29    /// Package that publishes the verifier function.
30    pub package_id: sui::types::Address,
31    /// Module containing the verifier function.
32    pub module_name: String,
33    /// Public verifier function name.
34    pub function_name: String,
35    /// Ordered immutable shared objects; object zero is the verifier witness.
36    pub verifier_objects: Vec<ExternalVerifierObjectInput>,
37}
38
39#[derive(Clone, Copy)]
40enum ToolCollateral<'a> {
41    Coin(&'a sui::types::ObjectReference),
42    AddressBalance(u64),
43}
44
45/// Registration data for an off chain [`ToolMeta`] and its initial network
46/// authorization key.
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct OffChainToolRegistration {
49    /// Metadata written to the tool registry.
50    pub meta: ToolMeta,
51    /// Initial Ed25519 public key bytes.
52    pub public_key: [u8; 32],
53    /// Proof that the initial key belongs to the tool identity.
54    pub pop_signature: [u8; 64],
55    /// Cost charged for one invocation in MIST.
56    pub invocation_cost_mist: u64,
57}
58
59impl ToolCollateral<'_> {
60    fn ptb_argument(self, tx: &mut move_boundary::NexusPtbBuilder) -> anyhow::Result<Argument> {
61        match self {
62            Self::Coin(coin) => Ok(tx.owned_object(coin)?),
63            Self::AddressBalance(amount) => {
64                let us_type = tx.objects().us_token.type_tag();
65                Ok(tx.withdraw_coin_from_address_balance(us_type, amount)?)
66            }
67        }
68    }
69
70    fn return_remainder(
71        self,
72        tx: &mut move_boundary::NexusPtbBuilder,
73        coin: Argument,
74        recipient: sui::types::Address,
75    ) -> anyhow::Result<()> {
76        if matches!(self, Self::AddressBalance(_)) {
77            let us_type = tx.objects().us_token.type_tag();
78            tx.send_coin_to_address_balance(us_type, coin, recipient)?;
79        }
80        Ok(())
81    }
82}
83
84#[derive(Clone, Copy)]
85struct RegisteredToolArguments {
86    tool: Argument,
87    owner_cap_over_tool: Argument,
88    cashier_admin_cap: Argument,
89}
90
91fn timeout_millis(timeout: Duration) -> anyhow::Result<u64> {
92    u64::try_from(timeout.as_millis()).context("tool timeout milliseconds do not fit in u64")
93}
94
95fn configure_registration(
96    tx: &mut move_boundary::NexusPtbBuilder,
97    register_result: Argument,
98) -> anyhow::Result<RegisteredToolArguments> {
99    let tool = tx.nested_result(register_result, 0)?;
100    let owner_cap_over_tool = tx.nested_result(register_result, 1)?;
101    let cashier_admin_cap = tx.nested_result(register_result, 2)?;
102
103    Ok(RegisteredToolArguments {
104        tool,
105        owner_cap_over_tool,
106        cashier_admin_cap,
107    })
108}
109
110fn build_external_verifier_registration(
111    tx: &mut move_boundary::NexusPtbBuilder,
112    tool_id: sui::types::Address,
113    input: &ExternalVerifierRegistrationInput,
114) -> anyhow::Result<Argument> {
115    let witness = input
116        .verifier_objects
117        .first()
118        .ok_or_else(|| anyhow::anyhow!("external verifier requires a witness at object zero"))?;
119    if input
120        .verifier_objects
121        .iter()
122        .any(|object| *object.object_ref.object_id() == sui::types::Address::ZERO)
123    {
124        bail!("external verifier object IDs must not be zero");
125    }
126    let mut unique_ids = HashSet::with_capacity(input.verifier_objects.len());
127    if input
128        .verifier_objects
129        .iter()
130        .any(|object| !unique_ids.insert(*object.object_ref.object_id()))
131    {
132        bail!("external verifier objects must be unique");
133    }
134
135    let tool_package = tx.context().require_package(PackageRole::Tool)?.storage_id;
136    let tool_id = tx.object_id(tool_id)?;
137    let package_id = tx.object_id(input.package_id)?;
138    let module_name = tx.ascii_string(&input.module_name)?;
139    let function_name = tx.ascii_string(&input.function_name)?;
140    let witness_argument = tx.shared_object(&witness.object_ref, false)?;
141    let registration = tx.call_function_with_type_args(
142        tool_package,
143        "external_verifier",
144        "new",
145        vec![witness.object_type.clone()],
146        vec![
147            tool_id,
148            package_id,
149            module_name,
150            function_name,
151            witness_argument,
152        ],
153    )?;
154
155    for object in input.verifier_objects.iter().skip(1) {
156        let object_argument = tx.shared_object(&object.object_ref, false)?;
157        tx.call_function_with_type_args(
158            tool_package,
159            "external_verifier",
160            "add_object",
161            vec![object.object_type.clone()],
162            vec![registration, object_argument],
163        )?;
164    }
165
166    Ok(registration)
167}
168
169fn finish_registrations(
170    tx: &mut move_boundary::NexusPtbBuilder,
171    registrations: &[RegisteredToolArguments],
172    owner: sui::types::Address,
173) -> anyhow::Result<()> {
174    for registration in registrations {
175        tx.call_target(
176            transfer_binding::public_share_object_target::<tool_registry_binding::Tool>,
177            vec![registration.tool],
178        )?;
179    }
180
181    let capabilities = registrations
182        .iter()
183        .flat_map(|registration| {
184            [
185                registration.owner_cap_over_tool,
186                registration.cashier_admin_cap,
187            ]
188        })
189        .collect();
190    let owner = tx.arg(&owner)?;
191    tx.transfer_objects(capabilities, owner)?;
192    Ok(())
193}
194
195fn register_off_chain_tool(
196    tx: &mut move_boundary::NexusPtbBuilder,
197    tool_registry: Argument,
198    meta: &ToolMeta,
199    invocation_cost_mist: u64,
200    pay_with: Argument,
201    clock: Argument,
202) -> anyhow::Result<Argument> {
203    meta.validate_registration()?;
204    let fqn = tx.ascii_string(meta.fqn.to_string())?;
205    let url = tx.arg(&meta.url.as_bytes().to_vec())?;
206    let description = tx.arg(&meta.description.as_bytes().to_vec())?;
207    let meta_schema = tx.meta_schema(&meta.meta_schema()?)?;
208    let timeout_ms = timeout_millis(meta.timeout)?;
209    let timeout_ms = tx.arg(&timeout_ms)?;
210    let invocation_cost_mist = tx.arg(&invocation_cost_mist)?;
211
212    tx.call_target(
213        tool_registry_binding::register_off_chain_tool_target,
214        vec![
215            tool_registry,
216            fqn,
217            url,
218            description,
219            meta_schema,
220            timeout_ms,
221            invocation_cost_mist,
222            pay_with,
223            clock,
224        ],
225    )
226}
227
228/// Builds a [`ProgrammableTransaction`] that registers an off chain tool using
229/// an owned coin as collateral.
230///
231/// # Errors
232///
233/// Returns an error if the timeout does not fit in `u64` milliseconds or the
234/// transaction cannot be built.
235pub fn register_off_chain_for_self_ptb(
236    objects: &NexusContext,
237    meta: &ToolMeta,
238    address: sui::types::Address,
239    collateral_coin: &sui::types::ObjectReference,
240    invocation_cost_mist: u64,
241) -> anyhow::Result<ProgrammableTransaction> {
242    register_off_chain_for_self_with_collateral_ptb(
243        objects,
244        meta,
245        address,
246        ToolCollateral::Coin(collateral_coin),
247        invocation_cost_mist,
248    )
249}
250
251/// Builds a [`ProgrammableTransaction`] that registers an off chain tool using
252/// `$US` collateral from the sender address balance.
253///
254/// This is the address balance counterpart to
255/// [`register_off_chain_for_self_ptb`].
256///
257/// # Errors
258///
259/// Returns an error if the timeout does not fit in `u64` milliseconds or the
260/// transaction cannot be built.
261pub fn register_off_chain_for_self_with_address_balance_ptb(
262    objects: &NexusContext,
263    meta: &ToolMeta,
264    address: sui::types::Address,
265    collateral_us: u64,
266    invocation_cost_mist: u64,
267) -> anyhow::Result<ProgrammableTransaction> {
268    register_off_chain_for_self_with_collateral_ptb(
269        objects,
270        meta,
271        address,
272        ToolCollateral::AddressBalance(collateral_us),
273        invocation_cost_mist,
274    )
275}
276
277fn register_off_chain_for_self_with_collateral_ptb(
278    objects: &NexusContext,
279    meta: &ToolMeta,
280    address: sui::types::Address,
281    collateral: ToolCollateral<'_>,
282    invocation_cost_mist: u64,
283) -> anyhow::Result<ProgrammableTransaction> {
284    move_boundary::ptb(objects, |tx| {
285        let tool_registry = tx.shared_root(&objects.tool_registry, true)?;
286        let pay_with = collateral.ptb_argument(tx)?;
287        let clock = tx.clock()?;
288        let register_result = register_off_chain_tool(
289            tx,
290            tool_registry,
291            meta,
292            invocation_cost_mist,
293            pay_with,
294            clock,
295        )?;
296        let registration = configure_registration(tx, register_result)?;
297        finish_registrations(tx, &[registration], address)?;
298        collateral.return_remainder(tx, pay_with, address)
299    })
300}
301
302fn batch_collateral_us(
303    registrations: &[OffChainToolRegistration],
304    collateral_per_tool_us: u64,
305) -> anyhow::Result<u64> {
306    if registrations.is_empty() {
307        bail!("off chain tool registration batch must not be empty");
308    }
309
310    let mut fqns = HashSet::with_capacity(registrations.len());
311    for (index, registration) in registrations.iter().enumerate() {
312        if !fqns.insert(&registration.meta.fqn) {
313            bail!(
314                "registration index {index} repeats tool FQN '{}'",
315                registration.meta.fqn
316            );
317        }
318    }
319
320    let tool_count = u64::try_from(registrations.len())
321        .context("tool registration count does not fit in u64")?;
322    collateral_per_tool_us
323        .checked_mul(tool_count)
324        .context("aggregate tool collateral overflows u64")
325}
326
327fn compose_off_chain_registration(
328    tx: &mut move_boundary::NexusPtbBuilder,
329    tool_registry: Argument,
330    pay_with: Argument,
331    clock: Argument,
332    registration: &OffChainToolRegistration,
333) -> anyhow::Result<RegisteredToolArguments> {
334    let register_result = register_off_chain_tool(
335        tx,
336        tool_registry,
337        &registration.meta,
338        registration.invocation_cost_mist,
339        pay_with,
340        clock,
341    )?;
342    let registered = configure_registration(tx, register_result)?;
343    let binding = super::network_auth::create_tool_binding_and_register_key(
344        tx,
345        registered.tool,
346        registered.owner_cap_over_tool,
347        registration.public_key,
348        registration.pop_signature,
349        None,
350    )?;
351    let network_auth = tx.objects().network_auth;
352    let network_auth = tx.shared_root(&network_auth, false)?;
353    tx.call_target(
354        registered_key_verifier_binding::configure_tool_target,
355        vec![
356            tool_registry,
357            registered.tool,
358            registered.owner_cap_over_tool,
359            network_auth,
360            binding,
361        ],
362    )?;
363    super::network_auth::share_binding(tx, binding)?;
364    Ok(registered)
365}
366
367/// Builds one [`ProgrammableTransaction`] that atomically registers off chain
368/// [`OffChainToolRegistration`] values, their initial response keys, and
369/// RegisteredKey verifier support.
370///
371/// The transaction uses one `$US` withdrawal from the owner address balance.
372///
373/// # Errors
374///
375/// Returns an error if the batch is empty, contains duplicate [`ToolFqn`]
376/// values, requires more collateral than fits in `u64`, contains an invalid
377/// timeout, or the transaction cannot be built.
378pub fn register_off_chain_batch_for_self_with_address_balance_ptb(
379    objects: &NexusContext,
380    registrations: &[OffChainToolRegistration],
381    owner: sui::types::Address,
382    collateral_per_tool_us: u64,
383) -> anyhow::Result<ProgrammableTransaction> {
384    let collateral_us = batch_collateral_us(registrations, collateral_per_tool_us)?;
385    let collateral = ToolCollateral::AddressBalance(collateral_us);
386
387    move_boundary::ptb(objects, |tx| {
388        let tool_registry = tx.shared_root(&objects.tool_registry, true)?;
389        let pay_with = collateral.ptb_argument(tx)?;
390        let clock = tx.clock()?;
391        let mut registered_tools = Vec::with_capacity(registrations.len());
392
393        for (index, registration) in registrations.iter().enumerate() {
394            let registered =
395                compose_off_chain_registration(tx, tool_registry, pay_with, clock, registration)
396                    .with_context(|| {
397                        format!(
398                            "build registration index {index} for '{}'",
399                            registration.meta.fqn
400                        )
401                    })?;
402            registered_tools.push(registered);
403        }
404
405        finish_registrations(tx, &registered_tools, owner)?;
406        collateral.return_remainder(tx, pay_with, owner)
407    })
408}
409
410/// Builds a [`ProgrammableTransaction`] that registers an onchain Nexus Tool
411/// using an owned coin as collateral.
412///
413/// [`OnchainToolMode`] selects the matching registry entrypoint.
414///
415/// # Errors
416///
417/// Returns an error if the timeout does not fit in `u64` milliseconds or the
418/// transaction cannot be built.
419#[allow(clippy::too_many_arguments)]
420pub fn register_on_chain_for_self_ptb(
421    objects: &NexusContext,
422    package_address: sui::types::Address,
423    module_name: &str,
424    fqn: &ToolFqn,
425    description: &str,
426    input_schema: &str,
427    output_schema: &str,
428    timeout: Duration,
429    tool_witness_id: sui::types::Address,
430    invocation_cost_mist: u64,
431    collateral_coin: &sui::types::ObjectReference,
432    address: sui::types::Address,
433    mode: OnchainToolMode,
434) -> anyhow::Result<ProgrammableTransaction> {
435    register_on_chain_for_self_with_collateral_ptb(
436        objects,
437        package_address,
438        module_name,
439        fqn,
440        description,
441        input_schema,
442        output_schema,
443        timeout,
444        tool_witness_id,
445        invocation_cost_mist,
446        ToolCollateral::Coin(collateral_coin),
447        address,
448        mode,
449    )
450}
451
452/// Builds a [`ProgrammableTransaction`] that registers an onchain Tool using
453/// `$US` collateral from the sender address balance.
454///
455/// This is the address balance counterpart to
456/// [`register_on_chain_for_self_ptb`].
457///
458/// # Errors
459///
460/// Returns an error if the timeout does not fit in `u64` milliseconds or the
461/// transaction cannot be built.
462#[allow(clippy::too_many_arguments)]
463pub fn register_on_chain_for_self_with_address_balance_ptb(
464    objects: &NexusContext,
465    package_address: sui::types::Address,
466    module_name: &str,
467    fqn: &ToolFqn,
468    description: &str,
469    input_schema: &str,
470    output_schema: &str,
471    timeout: Duration,
472    tool_witness_id: sui::types::Address,
473    invocation_cost_mist: u64,
474    collateral_us: u64,
475    address: sui::types::Address,
476    mode: OnchainToolMode,
477) -> anyhow::Result<ProgrammableTransaction> {
478    register_on_chain_for_self_with_collateral_ptb(
479        objects,
480        package_address,
481        module_name,
482        fqn,
483        description,
484        input_schema,
485        output_schema,
486        timeout,
487        tool_witness_id,
488        invocation_cost_mist,
489        ToolCollateral::AddressBalance(collateral_us),
490        address,
491        mode,
492    )
493}
494
495#[allow(clippy::too_many_arguments)]
496fn register_on_chain_for_self_with_collateral_ptb(
497    objects: &NexusContext,
498    package_address: sui::types::Address,
499    module_name: &str,
500    fqn: &ToolFqn,
501    description: &str,
502    input_schema: &str,
503    output_schema: &str,
504    timeout: Duration,
505    tool_witness_id: sui::types::Address,
506    invocation_cost_mist: u64,
507    collateral: ToolCollateral<'_>,
508    address: sui::types::Address,
509    mode: OnchainToolMode,
510) -> anyhow::Result<ProgrammableTransaction> {
511    move_boundary::ptb(objects, |tx| {
512        let tool_registry = tx.shared_root(&objects.tool_registry, true)?;
513        let package_addr = tx.arg(&package_address)?;
514        let module_name = tx.ascii_string(module_name)?;
515        let fqn = tx.ascii_string(fqn.to_string())?;
516        let description = tx.arg(&description.as_bytes().to_vec())?;
517        let meta_schema =
518            crate::move_bindings::interface::meta_schema::MetaSchema::from_onchain_json_schemas(
519                input_schema,
520                output_schema,
521            )?;
522        let meta_schema = tx.meta_schema(&meta_schema)?;
523        let timeout_ms = timeout_millis(timeout)?;
524        let timeout_ms = tx.arg(&timeout_ms)?;
525        let tool_witness_id = tx.object_id(tool_witness_id)?;
526        let invocation_cost_mist = tx.arg(&invocation_cost_mist)?;
527        let pay_with = collateral.ptb_argument(tx)?;
528        let clock = tx.clock()?;
529
530        let arguments = vec![
531            tool_registry,
532            package_addr,
533            module_name,
534            fqn,
535            description,
536            meta_schema,
537            timeout_ms,
538            tool_witness_id,
539            invocation_cost_mist,
540            pay_with,
541            clock,
542        ];
543        let register_result = match mode {
544            OnchainToolMode::Standard => tx.call_target(
545                tool_registry_binding::register_on_chain_tool_target,
546                arguments,
547            )?,
548            OnchainToolMode::WorkflowAuthorization => tx.call_target(
549                tool_registry_binding::register_on_chain_tool_with_workflow_authorization_cap_target,
550                arguments,
551            )?,
552        };
553
554        let registration = configure_registration(tx, register_result)?;
555        finish_registrations(tx, &[registration], address)?;
556        collateral.return_remainder(tx, pay_with, address)
557    })
558}
559
560/// Builds a transaction that updates a Tool invocation price.
561pub fn set_invocation_cost_ptb(
562    context: &NexusContext,
563    tool: &sui::types::ObjectReference,
564    cashier_admin: &sui::types::ObjectReference,
565    invocation_cost_mist: u64,
566) -> anyhow::Result<ProgrammableTransaction> {
567    move_boundary::ptb(context, |tx| {
568        let registry = tx.shared_root(&context.tool_registry, true)?;
569        let tool = tx.shared_object(tool, false)?;
570        let cashier_admin = tx.owned_object(cashier_admin)?;
571        let invocation_cost_mist = tx.arg(&invocation_cost_mist)?;
572        tx.call_target(
573            tool_registry_binding::set_invocation_cost_mist_target,
574            vec![registry, tool, cashier_admin, invocation_cost_mist],
575        )?;
576        Ok(())
577    })
578}
579
580/// Builds a transaction that changes the Registry verification status of a Tool.
581///
582/// The supplied `admin` must be the [`ToolRegistryAdminCap`](
583/// crate::move_bindings::tool::tool_registry::ToolRegistryAdminCap) for
584/// [`NexusContext::tool_registry`].
585pub fn set_verified_ptb(
586    context: &NexusContext,
587    tool: &sui::types::ObjectReference,
588    admin: &sui::types::ObjectReference,
589    verified: bool,
590) -> anyhow::Result<ProgrammableTransaction> {
591    move_boundary::ptb(context, |tx| {
592        let tool = tx.shared_object(tool, true)?;
593        let registry = tx.shared_root(&context.tool_registry, false)?;
594        let admin = tx.owned_object(admin)?;
595        let verified = tx.arg(&verified)?;
596        tx.call_target(
597            tool_registry_binding::set_verified_target,
598            vec![tool, registry, admin, verified],
599        )?;
600        Ok(())
601    })
602}
603
604/// Builds a transaction that unregisters a Tool from live protocol lookup.
605pub fn unregister_ptb(
606    context: &NexusContext,
607    tool: &sui::types::ObjectReference,
608    owner_cap: &sui::types::ObjectReference,
609) -> anyhow::Result<ProgrammableTransaction> {
610    move_boundary::ptb(context, |tx| {
611        let tool = tx.shared_object(tool, true)?;
612        let registry = tx.shared_root(&context.tool_registry, true)?;
613        let owner_cap = tx.owned_object(owner_cap)?;
614        let clock = tx.clock()?;
615        tx.call_target(
616            tool_registry_binding::unregister_target,
617            vec![tool, registry, owner_cap, clock],
618        )?;
619        Ok(())
620    })
621}
622
623/// Builds a transaction that points an on chain Tool at a new package.
624pub fn migrate_on_chain_tool_package_ptb(
625    context: &NexusContext,
626    tool: &sui::types::ObjectReference,
627    owner_cap: &sui::types::ObjectReference,
628    target_package: sui::types::Address,
629) -> anyhow::Result<ProgrammableTransaction> {
630    move_boundary::ptb(context, |tx| {
631        let tool = tx.shared_object(tool, true)?;
632        let owner_cap = tx.owned_object(owner_cap)?;
633        let target_package = tx.arg(&target_package)?;
634        tx.call_target(
635            tool_registry_binding::migrate_on_chain_tool_package_target,
636            vec![tool, owner_cap, target_package],
637        )?;
638        Ok(())
639    })
640}
641
642/// Builds a transaction that enables registered key verification for a Tool.
643pub fn configure_registered_key_verifier_ptb(
644    context: &NexusContext,
645    tool: &sui::types::ObjectReference,
646    owner_cap: &sui::types::ObjectReference,
647    tool_key_binding: &sui::types::ObjectReference,
648) -> anyhow::Result<ProgrammableTransaction> {
649    move_boundary::ptb(context, |tx| {
650        let registry = tx.shared_root(&context.tool_registry, true)?;
651        let tool = tx.shared_object(tool, false)?;
652        let owner_cap = tx.owned_object(owner_cap)?;
653        let network_auth = tx.shared_root(&context.network_auth, false)?;
654        let tool_key_binding = tx.shared_object(tool_key_binding, false)?;
655        tx.call_target(
656            registered_key_verifier_binding::configure_tool_target,
657            vec![registry, tool, owner_cap, network_auth, tool_key_binding],
658        )?;
659        Ok(())
660    })
661}
662
663/// Builds a transaction that installs one external verifier for a Tool.
664pub fn register_external_verifier_ptb(
665    context: &NexusContext,
666    tool: &sui::types::ObjectReference,
667    owner_cap: &sui::types::ObjectReference,
668    input: &ExternalVerifierRegistrationInput,
669) -> anyhow::Result<ProgrammableTransaction> {
670    move_boundary::ptb(context, |tx| {
671        let registry = tx.shared_root(&context.tool_registry, true)?;
672        let tool_argument = tx.shared_object(tool, false)?;
673        let owner_cap = tx.owned_object(owner_cap)?;
674        let registration = build_external_verifier_registration(tx, *tool.object_id(), input)?;
675        tx.call_target(
676            tool_registry_binding::register_external_verifier_target,
677            vec![registry, tool_argument, owner_cap, registration],
678        )?;
679        Ok(())
680    })
681}
682
683/// PTB template for claiming collateral for a Nexus Tool. The funds are
684/// transferred to the tx sender.
685pub fn claim_collateral_for_self_ptb(
686    objects: &NexusContext,
687    tool: &sui::types::ObjectReference,
688    owner_cap: &sui::types::ObjectReference,
689) -> anyhow::Result<ProgrammableTransaction> {
690    move_boundary::ptb(objects, |tx| {
691        let tool = tx.shared_object(tool, true)?;
692        let owner_cap = tx.owned_object(owner_cap)?;
693        let clock = tx.clock()?;
694
695        tx.call_target(
696            tool_registry_binding::claim_collateral_for_self_target,
697            vec![tool, owner_cap, clock],
698        )?;
699        Ok(())
700    })
701}
702
703/// Builds a transaction that updates the timeout of a registered Tool.
704pub fn update_tool_timeout_ptb(
705    context: &NexusContext,
706    tool: &sui::types::ObjectReference,
707    owner_cap: &sui::types::ObjectReference,
708    timeout: Duration,
709) -> anyhow::Result<ProgrammableTransaction> {
710    move_boundary::ptb(context, |tx| {
711        let tool = tx.shared_object(tool, false)?;
712        let registry = tx.shared_root(&context.tool_registry, true)?;
713        let owner_cap = tx.owned_object(owner_cap)?;
714        let timeout_ms = tx.arg(&timeout_millis(timeout)?)?;
715        tx.call_target(
716            tool_registry_binding::update_tool_timeout_target,
717            vec![tool, registry, owner_cap, timeout_ms],
718        )?;
719        Ok(())
720    })
721}
722
723/// Builds a transaction that replaces an off chain Tool URL.
724pub fn update_off_chain_tool_url_ptb(
725    context: &NexusContext,
726    tool: &sui::types::ObjectReference,
727    owner_cap: &sui::types::ObjectReference,
728    new_url: &str,
729) -> anyhow::Result<ProgrammableTransaction> {
730    move_boundary::ptb(context, |tx| {
731        let tool = tx.shared_object(tool, true)?;
732        let owner_cap = tx.owned_object(owner_cap)?;
733        let new_url = tx.arg(&new_url.as_bytes().to_vec())?;
734        tx.call_target(
735            tool_registry_binding::update_off_chain_tool_url_target,
736            vec![tool, owner_cap, new_url],
737        )?;
738        Ok(())
739    })
740}
741
742/// Builds a transaction that replaces a registered Tool description.
743pub fn update_tool_metadata_ptb(
744    context: &NexusContext,
745    tool: &sui::types::ObjectReference,
746    owner_cap: &sui::types::ObjectReference,
747    description: &str,
748) -> anyhow::Result<ProgrammableTransaction> {
749    move_boundary::ptb(context, |tx| {
750        let tool = tx.shared_object(tool, true)?;
751        let owner_cap = tx.owned_object(owner_cap)?;
752        let description = tx.arg(&description.as_bytes().to_vec())?;
753        tx.call_target(
754            tool_registry_binding::update_tool_metadata_target,
755            vec![tool, owner_cap, description],
756        )?;
757        Ok(())
758    })
759}
760
761#[cfg(test)]
762mod tests {
763    use {
764        super::*,
765        crate::{test_utils::sui_mocks, types::PackageRole},
766        sui::types::{Command, WithdrawFrom},
767        talus_sui_move_call::CallArg,
768    };
769
770    const OFFCHAIN_INPUT_SCHEMA: &[u8] = br#"{"type":"object","properties":{}}"#;
771    const OFFCHAIN_OUTPUT_SCHEMA: &[u8] = br#"{"oneOf":[{"const":"Ok"}]}"#;
772    const ONCHAIN_INPUT_SCHEMA: &str = "{}";
773    const ONCHAIN_OUTPUT_SCHEMA: &str = r#"{"Ok":{"fields":{}}}"#;
774
775    fn addr(value: &'static str) -> sui::types::Address {
776        sui::types::Address::from_static(value)
777    }
778
779    fn object_ref(value: &'static str, version: u64, digest: u8) -> sui::types::ObjectReference {
780        sui::types::ObjectReference::new(
781            addr(value),
782            version,
783            sui::types::Digest::from([digest; 32]),
784        )
785    }
786
787    fn nexus_objects() -> NexusContext {
788        sui_mocks::mock_nexus_context()
789    }
790
791    fn move_calls(ptb: &ProgrammableTransaction) -> Vec<&sui::types::MoveCall> {
792        ptb.commands
793            .iter()
794            .filter_map(|command| match command {
795                Command::MoveCall(call) => Some(call),
796                _ => None,
797            })
798            .collect()
799    }
800
801    fn input_for_argument<'a>(
802        ptb: &'a ProgrammableTransaction,
803        argument: &Argument,
804    ) -> &'a CallArg {
805        let Argument::Input(index) = argument else {
806            panic!("expected input argument, got {argument:?}");
807        };
808        &ptb.inputs[usize::from(*index)]
809    }
810
811    fn assert_us_address_balance_withdrawal(
812        objects: &NexusContext,
813        ptb: &ProgrammableTransaction,
814        expected_amount: u64,
815    ) {
816        let withdrawal = ptb
817            .inputs
818            .iter()
819            .find_map(|input| match input {
820                CallArg::FundsWithdrawal(withdrawal) => Some(withdrawal),
821                _ => None,
822            })
823            .expect("registration must withdraw its collateral");
824
825        assert_eq!(withdrawal.source(), WithdrawFrom::Sender);
826        assert_eq!(withdrawal.amount(), Some(expected_amount));
827        let us_type = objects.us_token.type_tag();
828        assert_eq!(withdrawal.coin_type(), &us_type);
829
830        for function in ["redeem_funds", "send_funds"] {
831            let call = ptb
832                .commands
833                .iter()
834                .find_map(|command| match command {
835                    Command::MoveCall(call)
836                        if call.package == sui::types::Address::TWO
837                            && call.module.as_str() == "coin"
838                            && call.function.as_str() == function =>
839                    {
840                        Some(call)
841                    }
842                    _ => None,
843                })
844                .unwrap_or_else(|| panic!("expected coin::{function} call"));
845            assert_eq!(call.type_arguments, vec![us_type.clone()]);
846        }
847    }
848
849    fn batch_registration(name: &str, key_byte: u8) -> OffChainToolRegistration {
850        OffChainToolRegistration {
851            meta: ToolMeta {
852                fqn: format!("xyz.taluslabs.{name}@1").parse().unwrap(),
853                url: format!("https://example.com/{name}"),
854                description: name.to_string(),
855                timeout: Duration::from_secs(1),
856                input_schema: OFFCHAIN_INPUT_SCHEMA.to_vec(),
857                output_schema: OFFCHAIN_OUTPUT_SCHEMA.to_vec(),
858            },
859            public_key: [key_byte; 32],
860            pop_signature: [key_byte; 64],
861            invocation_cost_mist: u64::from(key_byte),
862        }
863    }
864
865    fn move_call_indices(
866        ptb: &ProgrammableTransaction,
867        module: &str,
868        function: &str,
869    ) -> Vec<usize> {
870        ptb.commands
871            .iter()
872            .enumerate()
873            .filter_map(|(index, command)| match command {
874                Command::MoveCall(call)
875                    if call.module.as_str() == module && call.function.as_str() == function =>
876                {
877                    Some(index)
878                }
879                _ => None,
880            })
881            .collect()
882    }
883
884    fn object_type(
885        address: &'static str,
886        module: &'static str,
887        name: &'static str,
888    ) -> sui::types::TypeTag {
889        sui::types::TypeTag::Struct(Box::new(sui::types::StructTag::new(
890            addr(address),
891            sui::types::Identifier::from_static(module),
892            sui::types::Identifier::from_static(name),
893            vec![],
894        )))
895    }
896
897    #[test]
898    fn unregister_uses_the_tool_and_current_registry() {
899        let objects = nexus_objects();
900        let tool = object_ref("0x20", 2, 20);
901        let owner_cap = object_ref("0x21", 3, 21);
902        let ptb = unregister_ptb(&objects, &tool, &owner_cap).unwrap();
903
904        let calls = move_calls(&ptb);
905        assert_eq!(calls.len(), 1);
906        assert_eq!(calls[0].module.as_str(), "tool_registry");
907        assert_eq!(calls[0].function.as_str(), "unregister");
908        assert_eq!(calls[0].arguments.len(), 4);
909        let tool_input = ptb
910            .inputs
911            .iter()
912            .find_map(|input| match input {
913                CallArg::Shared(shared) if shared.object_id() == *tool.object_id() => Some(shared),
914                _ => None,
915            })
916            .expect("unregister must use the mutable Tool");
917        assert!(tool_input.mutability().is_mutable());
918        assert!(ptb.inputs.iter().any(|input| {
919            matches!(input, CallArg::Shared(shared) if shared.object_id() == objects.tool_registry.object_id())
920        }));
921    }
922
923    #[test]
924    fn set_verified_preserves_move_authority_and_argument_order() {
925        let objects = nexus_objects();
926        let tool = object_ref("0x20", 2, 20);
927        let admin = object_ref("0x21", 3, 21);
928
929        for verified in [false, true] {
930            let ptb = set_verified_ptb(&objects, &tool, &admin, verified).unwrap();
931            let calls = move_calls(&ptb);
932            assert_eq!(calls.len(), 1);
933            let call = calls[0];
934            assert_eq!(
935                call.package,
936                objects
937                    .require_package(PackageRole::Tool)
938                    .unwrap()
939                    .storage_id
940            );
941            assert_eq!(call.module.as_str(), "tool_registry");
942            assert_eq!(call.function.as_str(), "set_verified");
943            assert_eq!(call.arguments.len(), 4);
944
945            let CallArg::Shared(tool_input) = input_for_argument(&ptb, &call.arguments[0]) else {
946                panic!("Tool must be a shared input");
947            };
948            assert_eq!(tool_input.object_id(), *tool.object_id());
949            assert_eq!(tool_input.version(), tool.version());
950            assert!(tool_input.mutability().is_mutable());
951
952            let CallArg::Shared(registry_input) = input_for_argument(&ptb, &call.arguments[1])
953            else {
954                panic!("Tool Registry must be a shared input");
955            };
956            assert_eq!(
957                registry_input.object_id(),
958                objects.tool_registry.object_id()
959            );
960            assert_eq!(
961                registry_input.version(),
962                objects.tool_registry.initial_shared_version
963            );
964            assert!(!registry_input.mutability().is_mutable());
965
966            let CallArg::ImmutableOrOwned(admin_input) =
967                input_for_argument(&ptb, &call.arguments[2])
968            else {
969                panic!("Registry admin must be an owned input");
970            };
971            assert_eq!(admin_input, &admin);
972
973            let CallArg::Pure(verified_input) = input_for_argument(&ptb, &call.arguments[3]) else {
974                panic!("verification status must be a pure input");
975            };
976            assert_eq!(verified_input.as_slice(), &[u8::from(verified)]);
977        }
978    }
979
980    #[test]
981    fn update_url_uses_tool_and_owner_authority() {
982        let objects = nexus_objects();
983        let ptb = update_off_chain_tool_url_ptb(
984            &objects,
985            &object_ref("0x20", 2, 20),
986            &object_ref("0x21", 3, 21),
987            "https://example.com/new",
988        )
989        .unwrap();
990        let calls = move_calls(&ptb);
991        assert_eq!(calls.len(), 1);
992        assert_eq!(calls[0].module.as_str(), "tool_registry");
993        assert_eq!(calls[0].function.as_str(), "update_off_chain_tool_url");
994        assert_eq!(calls[0].arguments.len(), 3);
995    }
996
997    #[test]
998    fn external_registration_keeps_witness_first_and_appends_objects_in_order() {
999        let objects = nexus_objects();
1000        let tool = object_ref("0x20", 2, 20);
1001        let owner_cap = object_ref("0x21", 3, 21);
1002        let witness_type = object_type("0x40", "state", "Witness");
1003        let config_type = object_type("0x40", "state", "Config");
1004        let input = ExternalVerifierRegistrationInput {
1005            package_id: addr("0x41"),
1006            module_name: "verifier".to_string(),
1007            function_name: "verify".to_string(),
1008            verifier_objects: vec![
1009                ExternalVerifierObjectInput {
1010                    object_ref: object_ref("0x42", 5, 42),
1011                    object_type: witness_type.clone(),
1012                },
1013                ExternalVerifierObjectInput {
1014                    object_ref: object_ref("0x43", 6, 43),
1015                    object_type: config_type.clone(),
1016                },
1017            ],
1018        };
1019        let ptb = register_external_verifier_ptb(&objects, &tool, &owner_cap, &input).unwrap();
1020        let calls = move_calls(&ptb)
1021            .into_iter()
1022            .filter(|call| {
1023                (call.module.as_str() == "external_verifier"
1024                    && matches!(call.function.as_str(), "new" | "add_object"))
1025                    || (call.module.as_str() == "tool_registry"
1026                        && call.function.as_str() == "register_external_verifier")
1027            })
1028            .collect::<Vec<_>>();
1029        assert_eq!(
1030            calls
1031                .iter()
1032                .map(|call| (call.module.as_str(), call.function.as_str()))
1033                .collect::<Vec<_>>(),
1034            vec![
1035                ("external_verifier", "new"),
1036                ("external_verifier", "add_object"),
1037                ("tool_registry", "register_external_verifier"),
1038            ]
1039        );
1040        assert_eq!(calls[0].type_arguments, vec![witness_type]);
1041        assert_eq!(calls[1].type_arguments, vec![config_type]);
1042        assert_eq!(calls[0].arguments.len(), 5);
1043        assert_eq!(calls[1].arguments.len(), 2);
1044        assert_eq!(calls[2].arguments.len(), 4);
1045    }
1046
1047    #[test]
1048    fn external_registration_requires_nonzero_unique_witness_and_objects() {
1049        let objects = nexus_objects();
1050        let tool = object_ref("0x20", 2, 20);
1051        let owner_cap = object_ref("0x21", 3, 21);
1052        let base = ExternalVerifierRegistrationInput {
1053            package_id: addr("0x41"),
1054            module_name: "verifier".to_string(),
1055            function_name: "verify".to_string(),
1056            verifier_objects: vec![],
1057        };
1058
1059        assert!(
1060            register_external_verifier_ptb(&objects, &tool, &owner_cap, &base)
1061                .unwrap_err()
1062                .to_string()
1063                .contains("witness at object zero")
1064        );
1065
1066        let object_type = object_type("0x40", "state", "Witness");
1067        let zero = ExternalVerifierRegistrationInput {
1068            verifier_objects: vec![ExternalVerifierObjectInput {
1069                object_ref: object_ref("0x0", 1, 1),
1070                object_type: object_type.clone(),
1071            }],
1072            ..base.clone()
1073        };
1074        assert!(
1075            register_external_verifier_ptb(&objects, &tool, &owner_cap, &zero)
1076                .unwrap_err()
1077                .to_string()
1078                .contains("must not be zero")
1079        );
1080
1081        let duplicate_ref = object_ref("0x42", 5, 42);
1082        let duplicate = ExternalVerifierRegistrationInput {
1083            verifier_objects: vec![
1084                ExternalVerifierObjectInput {
1085                    object_ref: duplicate_ref.clone(),
1086                    object_type: object_type.clone(),
1087                },
1088                ExternalVerifierObjectInput {
1089                    object_ref: duplicate_ref,
1090                    object_type,
1091                },
1092            ],
1093            ..base
1094        };
1095        assert!(
1096            register_external_verifier_ptb(&objects, &tool, &owner_cap, &duplicate)
1097                .unwrap_err()
1098                .to_string()
1099                .contains("must be unique")
1100        );
1101    }
1102
1103    #[test]
1104    fn single_off_chain_registration_shares_tool_and_transfers_authority() {
1105        let objects = sui_mocks::mock_nexus_context();
1106        let owner = sui_mocks::mock_sui_address();
1107        let registration = batch_registration("single", 6);
1108        let ptb = register_off_chain_for_self_with_address_balance_ptb(
1109            &objects,
1110            &registration.meta,
1111            owner,
1112            7,
1113            registration.invocation_cost_mist,
1114        )
1115        .unwrap();
1116
1117        for (module, function) in [
1118            ("tool_registry", "register_off_chain_tool"),
1119            ("transfer", "public_share_object"),
1120        ] {
1121            assert_eq!(move_call_indices(&ptb, module, function).len(), 1);
1122        }
1123        let register = move_calls(&ptb)
1124            .into_iter()
1125            .find(|call| {
1126                call.module.as_str() == "tool_registry"
1127                    && call.function.as_str() == "register_off_chain_tool"
1128            })
1129            .expect("single registration must create the Tool and both capabilities");
1130        assert_eq!(register.arguments.len(), 9);
1131        let transfer = ptb
1132            .commands
1133            .iter()
1134            .find_map(|command| match command {
1135                Command::TransferObjects(transfer) => Some(transfer),
1136                _ => None,
1137            })
1138            .expect("single registration must transfer both capabilities");
1139        assert_eq!(transfer.objects.len(), 2);
1140        assert_us_address_balance_withdrawal(&objects, &ptb, 7);
1141    }
1142
1143    #[test]
1144    fn atomic_batch_rejects_an_empty_catalog() {
1145        let objects = sui_mocks::mock_nexus_context();
1146        let owner = sui_mocks::mock_sui_address();
1147
1148        let error =
1149            register_off_chain_batch_for_self_with_address_balance_ptb(&objects, &[], owner, 1)
1150                .unwrap_err();
1151        assert!(error.to_string().contains("must not be empty"));
1152    }
1153
1154    #[test]
1155    fn atomic_batch_rejects_duplicate_tool_names() {
1156        let objects = sui_mocks::mock_nexus_context();
1157        let owner = sui_mocks::mock_sui_address();
1158
1159        let duplicate = batch_registration("duplicate", 1);
1160        let error = register_off_chain_batch_for_self_with_address_balance_ptb(
1161            &objects,
1162            &[duplicate.clone(), duplicate],
1163            owner,
1164            1,
1165        )
1166        .unwrap_err();
1167        assert!(error.to_string().contains("index 1"));
1168        assert!(error.to_string().contains("xyz.taluslabs.duplicate@1"));
1169    }
1170
1171    #[test]
1172    fn atomic_batch_rejects_collateral_overflow() {
1173        let objects = sui_mocks::mock_nexus_context();
1174        let owner = sui_mocks::mock_sui_address();
1175
1176        let error = register_off_chain_batch_for_self_with_address_balance_ptb(
1177            &objects,
1178            &[
1179                batch_registration("overflow_a", 2),
1180                batch_registration("overflow_b", 3),
1181            ],
1182            owner,
1183            u64::MAX,
1184        )
1185        .unwrap_err();
1186        assert!(error.to_string().contains("collateral"));
1187    }
1188
1189    #[test]
1190    fn atomic_batch_uses_one_withdrawal_and_finishes_after_every_registration() {
1191        let objects = sui_mocks::mock_nexus_context();
1192        let owner = sui_mocks::mock_sui_address();
1193        let registrations = [
1194            batch_registration("atomic_a", 4),
1195            batch_registration("atomic_b", 5),
1196        ];
1197
1198        let first = register_off_chain_batch_for_self_with_address_balance_ptb(
1199            &objects,
1200            &registrations,
1201            owner,
1202            7,
1203        )
1204        .unwrap();
1205        let second = register_off_chain_batch_for_self_with_address_balance_ptb(
1206            &objects,
1207            &registrations,
1208            owner,
1209            7,
1210        )
1211        .unwrap();
1212
1213        assert_eq!(first, second);
1214        assert_us_address_balance_withdrawal(&objects, &first, 14);
1215        assert_eq!(
1216            first
1217                .inputs
1218                .iter()
1219                .filter(|input| matches!(input, CallArg::FundsWithdrawal(_)))
1220                .count(),
1221            1
1222        );
1223        assert_eq!(
1224            move_call_indices(&first, "tool_registry", "register_off_chain_tool").len(),
1225            2
1226        );
1227        let key_calls = move_call_indices(&first, "network_auth", "register_key");
1228        assert_eq!(key_calls.len(), 2);
1229        let last_key_call = *key_calls.last().expect("batch must register every key");
1230        let verifier_calls = move_call_indices(&first, "registered_key_verifier", "configure_tool");
1231        assert_eq!(verifier_calls.len(), 2);
1232        assert!(
1233            key_calls
1234                .iter()
1235                .zip(&verifier_calls)
1236                .all(|(key, verifier)| key < verifier),
1237            "each active response key must exist before RegisteredKey support is enabled"
1238        );
1239
1240        let tool_type = crate::move_bindings::type_tag::<tool_registry_binding::Tool>(&objects);
1241        let tool_share_calls = first
1242            .commands
1243            .iter()
1244            .enumerate()
1245            .filter_map(|(index, command)| match command {
1246                Command::MoveCall(call)
1247                    if call.module.as_str() == "transfer"
1248                        && call.function.as_str() == "public_share_object"
1249                        && call.type_arguments.as_slice() == std::slice::from_ref(&tool_type) =>
1250                {
1251                    Some(index)
1252                }
1253                _ => None,
1254            })
1255            .collect::<Vec<_>>();
1256        assert_eq!(tool_share_calls.len(), 2);
1257        assert!(tool_share_calls.iter().all(|index| *index > last_key_call));
1258        let last_tool_share_call = *tool_share_calls
1259            .last()
1260            .expect("batch must share every tool");
1261
1262        let transfer = first
1263            .commands
1264            .iter()
1265            .enumerate()
1266            .find_map(|(index, command)| match command {
1267                Command::TransferObjects(transfer) => Some((index, transfer)),
1268                _ => None,
1269            })
1270            .expect("batch must transfer capabilities");
1271        assert_eq!(transfer.1.objects.len(), 4);
1272        assert!(transfer.0 > last_tool_share_call);
1273
1274        for object_id in [
1275            objects.tool_registry.object_id(),
1276            objects.network_auth.object_id(),
1277            move_boundary::CLOCK_OBJECT_ID,
1278        ] {
1279            assert_eq!(
1280                first
1281                    .inputs
1282                    .iter()
1283                    .filter(|input| {
1284                        matches!(input, CallArg::Shared(shared) if shared.object_id() == object_id)
1285                    })
1286                    .count(),
1287                1
1288            );
1289        }
1290    }
1291
1292    #[test]
1293    fn registered_key_configuration_uses_the_key_validating_boundary() {
1294        let objects = sui_mocks::mock_nexus_context();
1295        let tool = sui_mocks::mock_sui_object_ref();
1296        let owner_cap = sui_mocks::mock_sui_object_ref();
1297        let tool_key_binding = sui_mocks::mock_sui_object_ref();
1298
1299        let ptb =
1300            configure_registered_key_verifier_ptb(&objects, &tool, &owner_cap, &tool_key_binding)
1301                .unwrap();
1302
1303        assert_eq!(
1304            move_call_indices(&ptb, "registered_key_verifier", "configure_tool").len(),
1305            1
1306        );
1307        assert!(
1308            move_call_indices(&ptb, "tool_registry", "configure_registered_key_support").is_empty(),
1309            "SDK callers must not bypass active key validation"
1310        );
1311    }
1312
1313    #[test]
1314    fn off_chain_registration_can_source_collateral_from_address_balance() {
1315        let objects = sui_mocks::mock_nexus_context();
1316        let address = sui_mocks::mock_sui_address();
1317        let meta = ToolMeta {
1318            fqn: "xyz.taluslabs.example@1".parse().unwrap(),
1319            url: "https://example.com".into(),
1320            description: "example".into(),
1321            timeout: Duration::from_secs(1),
1322            input_schema: OFFCHAIN_INPUT_SCHEMA.to_vec(),
1323            output_schema: OFFCHAIN_OUTPUT_SCHEMA.to_vec(),
1324        };
1325
1326        let ptb =
1327            register_off_chain_for_self_with_address_balance_ptb(&objects, &meta, address, 42, 7)
1328                .unwrap();
1329
1330        assert_us_address_balance_withdrawal(&objects, &ptb, 42);
1331    }
1332
1333    #[test]
1334    fn on_chain_registration_scopes_generated_targets_and_uses_address_balance() {
1335        let objects = sui_mocks::mock_nexus_context();
1336        let address = sui_mocks::mock_sui_address();
1337        let package = sui_mocks::mock_sui_address();
1338        let witness = sui_mocks::mock_sui_address();
1339        let fqn = "xyz.taluslabs.example@1".parse().unwrap();
1340
1341        for (mode, expected_function) in [
1342            (
1343                crate::types::OnchainToolMode::Standard,
1344                "register_on_chain_tool",
1345            ),
1346            (
1347                crate::types::OnchainToolMode::WorkflowAuthorization,
1348                "register_on_chain_tool_with_workflow_authorization_cap",
1349            ),
1350        ] {
1351            let ptb = register_on_chain_for_self_with_address_balance_ptb(
1352                &objects,
1353                package,
1354                "example",
1355                &fqn,
1356                "example",
1357                ONCHAIN_INPUT_SCHEMA,
1358                ONCHAIN_OUTPUT_SCHEMA,
1359                Duration::from_secs(1),
1360                witness,
1361                7,
1362                42,
1363                address,
1364                mode,
1365            )
1366            .unwrap();
1367
1368            assert_us_address_balance_withdrawal(&objects, &ptb, 42);
1369            let registration = move_calls(&ptb)
1370                .into_iter()
1371                .find(|call| {
1372                    call.module.as_str() == "tool_registry"
1373                        && call.function.as_str() == expected_function
1374                })
1375                .expect("on chain registration call");
1376            assert_eq!(
1377                registration.package,
1378                objects
1379                    .require_package(PackageRole::Tool)
1380                    .unwrap()
1381                    .storage_id
1382            );
1383            assert_eq!(registration.arguments.len(), 11);
1384        }
1385    }
1386
1387    #[test]
1388    fn on_chain_registration_rejects_timeout_that_does_not_fit_in_milliseconds() {
1389        let objects = sui_mocks::mock_nexus_context();
1390        let address = sui_mocks::mock_sui_address();
1391        let package = sui_mocks::mock_sui_address();
1392        let witness = sui_mocks::mock_sui_address();
1393        let fqn = "xyz.taluslabs.example@1".parse().unwrap();
1394
1395        let error = register_on_chain_for_self_with_address_balance_ptb(
1396            &objects,
1397            package,
1398            "example",
1399            &fqn,
1400            "example",
1401            ONCHAIN_INPUT_SCHEMA,
1402            ONCHAIN_OUTPUT_SCHEMA,
1403            Duration::MAX,
1404            witness,
1405            7,
1406            42,
1407            address,
1408            OnchainToolMode::Standard,
1409        )
1410        .unwrap_err();
1411
1412        assert!(error.to_string().contains("timeout milliseconds"));
1413    }
1414
1415    #[test]
1416    fn off_chain_registration_rejects_timeout_that_does_not_fit_in_milliseconds() {
1417        let objects = sui_mocks::mock_nexus_context();
1418        let mut registration = batch_registration("timeout", 1);
1419        registration.meta.timeout = Duration::MAX;
1420
1421        let error = register_off_chain_for_self_with_address_balance_ptb(
1422            &objects,
1423            &registration.meta,
1424            addr("0x20"),
1425            1,
1426            registration.invocation_cost_mist,
1427        )
1428        .unwrap_err();
1429
1430        assert!(error.to_string().contains("timeout milliseconds"));
1431    }
1432}