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 pub object_ref: sui::types::ObjectReference,
22 pub object_type: sui::types::TypeTag,
24}
25
26#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct ExternalVerifierRegistrationInput {
29 pub package_id: sui::types::Address,
31 pub module_name: String,
33 pub function_name: String,
35 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#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct OffChainToolRegistration {
49 pub meta: ToolMeta,
51 pub public_key: [u8; 32],
53 pub pop_signature: [u8; 64],
55 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
228pub 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
251pub 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(®istration.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 ®istration.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
367pub 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, ®istered_tools, owner)?;
406 collateral.return_remainder(tx, pay_with, owner)
407 })
408}
409
410#[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#[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
560pub 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
580pub 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
604pub 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
623pub 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
642pub 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
663pub 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
683pub 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
703pub 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
723pub 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
742pub 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 ®istration.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 ®istrations,
1201 owner,
1202 7,
1203 )
1204 .unwrap();
1205 let second = register_off_chain_batch_for_self_with_address_balance_ptb(
1206 &objects,
1207 ®istrations,
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 ®istration.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}