1use {
4 crate::{
5 move_bindings::{
6 interface::graph::RuntimeVertex,
7 move_std::type_name::TypeName,
8 scheduler::invocation_adapter as scheduler_invocation_adapter_binding,
9 tool::invocation::Invocation,
10 workflow::invocation_adapter as invocation_adapter_binding,
11 },
12 move_boundary,
13 sui,
14 transactions::dag::OnchainToolArgument,
15 types::{NexusContext, PackageRole},
16 },
17 std::str::FromStr,
18};
19
20#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct InvocationPolicyCall {
29 pub policy: TypeName,
30 pub arguments: Vec<OnchainToolArgument>,
31}
32
33#[derive(Clone, Copy, Debug)]
35pub struct InvocationTarget<'a> {
36 pub walk_index: u64,
37 pub vertex: &'a RuntimeVertex,
38}
39
40fn policy_type_name(context: &NexusContext, module: &str) -> anyhow::Result<TypeName> {
41 let origin = context.type_origin(PackageRole::Tool, module, "Policy")?;
42 Ok(TypeName::new(&format!(
43 "{}::{module}::Policy",
44 hex::encode(origin.as_bytes()),
45 )))
46}
47
48impl InvocationPolicyCall {
49 pub fn new(policy: TypeName, arguments: Vec<OnchainToolArgument>) -> Self {
51 Self { policy, arguments }
52 }
53
54 pub fn fixed_price(context: &NexusContext) -> anyhow::Result<Self> {
56 Ok(Self::new(
57 policy_type_name(context, "fixed_price")?,
58 Vec::new(),
59 ))
60 }
61
62 pub fn finite_credits(
64 context: &NexusContext,
65 credits: sui::types::Address,
66 initial_shared_version: sui::types::Version,
67 ) -> anyhow::Result<Self> {
68 Ok(Self::new(
69 Self::finite_credits_policy(context)?,
70 vec![OnchainToolArgument::SharedObject {
71 object_id: credits,
72 initial_shared_version,
73 mutable: true,
74 }],
75 ))
76 }
77
78 pub fn finite_credits_policy(context: &NexusContext) -> anyhow::Result<TypeName> {
80 policy_type_name(context, "finite_credits")
81 }
82
83 pub fn time_pass(
85 context: &NexusContext,
86 pass: sui::types::Address,
87 initial_shared_version: sui::types::Version,
88 ) -> anyhow::Result<Self> {
89 Ok(Self::new(
90 policy_type_name(context, "time_pass")?,
91 vec![OnchainToolArgument::SharedObject {
92 object_id: pass,
93 initial_shared_version,
94 mutable: false,
95 }],
96 ))
97 }
98}
99
100pub(crate) fn policy_target(
101 context: &NexusContext,
102 policy: &TypeName,
103) -> anyhow::Result<(sui::types::Address, String)> {
104 let mut parts = policy.as_str().split("::");
105 let package = parts
106 .next()
107 .ok_or_else(|| anyhow::anyhow!("Invocation policy TypeName has no package"))?;
108 let module = parts
109 .next()
110 .ok_or_else(|| anyhow::anyhow!("Invocation policy TypeName has no module"))?;
111 let witness = parts
112 .next()
113 .ok_or_else(|| anyhow::anyhow!("Invocation policy TypeName has no witness"))?;
114 anyhow::ensure!(
115 parts.next().is_none() && witness == "Policy",
116 "Invocation policy TypeName must be '<package>::<module>::Policy'"
117 );
118 let defining_package = sui::types::Address::from_str(package).map_err(|error| {
119 anyhow::anyhow!("Invalid Invocation policy package '{package}': {error}")
120 })?;
121 let tool_package = context.require_package(PackageRole::Tool)?;
122 let call_package = if tool_package.contains_package(defining_package) {
123 tool_package.storage_id
124 } else {
125 defining_package
126 };
127 Ok((call_package, module.to_owned()))
128}
129
130fn prepare_authorization(
131 transaction: &mut move_boundary::NexusPtbBuilder,
132 cashier: sui::types::Argument,
133 dag: sui::types::Argument,
134 execution: sui::types::Argument,
135 target: InvocationTarget<'_>,
136 policy: &InvocationPolicyCall,
137) -> anyhow::Result<(
138 sui::types::Argument,
139 sui::types::Argument,
140 sui::types::Argument,
141 sui::types::Argument,
142)> {
143 let vertex = super::dag::runtime_vertex_arg(transaction, target.vertex)?;
144 let clock = transaction.clock()?;
145 let request = transaction.call_target(
146 invocation_adapter_binding::new_request_target,
147 vec![cashier, dag, execution, vertex, clock],
148 )?;
149 let mut policy_arguments = Vec::with_capacity(policy.arguments.len() + 2);
150 policy_arguments.push(cashier);
151 policy_arguments.extend(
152 policy
153 .arguments
154 .iter()
155 .map(|argument| {
156 super::dag::prepare_onchain_tool_argument(
157 transaction,
158 argument,
159 &Default::default(),
160 )
161 })
162 .collect::<anyhow::Result<Vec<_>>>()?,
163 );
164 policy_arguments.push(request);
165 let (package, module) = policy_target(transaction.context(), &policy.policy)?;
166 let authorized =
167 transaction.call_function(package, module, "get_invocation", policy_arguments)?;
168 let walk_index = transaction.arg(&target.walk_index)?;
169 Ok((walk_index, vertex, authorized, clock))
170}
171
172#[allow(clippy::too_many_arguments)]
176pub fn authorize(
177 transaction: &mut move_boundary::NexusPtbBuilder,
178 cashier: sui::types::Argument,
179 dag: sui::types::Argument,
180 execution: sui::types::Argument,
181 leader_registry: sui::types::Argument,
182 leader_cap: sui::types::Argument,
183 target: InvocationTarget<'_>,
184 policy: &InvocationPolicyCall,
185 submission_gas_charge: u64,
186) -> anyhow::Result<sui::types::Argument> {
187 let (walk_index, vertex, authorized, clock) =
188 prepare_authorization(transaction, cashier, dag, execution, target, policy)?;
189 let submission_gas_charge = transaction.arg(&submission_gas_charge)?;
190 let runtime_authority = transaction.runtime_authority(false)?;
191 transaction.call_target(
192 scheduler_invocation_adapter_binding::lock_and_request_target,
193 vec![
194 runtime_authority,
195 dag,
196 execution,
197 leader_registry,
198 leader_cap,
199 walk_index,
200 vertex,
201 authorized,
202 submission_gas_charge,
203 clock,
204 ],
205 )
206}
207
208#[allow(clippy::too_many_arguments)]
212pub fn authorize_ptb(
213 context: &NexusContext,
214 cashier: &sui::types::ObjectReference,
215 dag: &sui::types::ObjectReference,
216 execution: &sui::types::ObjectReference,
217 leader_cap: &sui::types::ObjectReference,
218 target: InvocationTarget<'_>,
219 policy: &InvocationPolicyCall,
220 submission_gas_charge: u64,
221) -> anyhow::Result<sui::types::ProgrammableTransaction> {
222 move_boundary::ptb(context, |transaction| {
223 let cashier = transaction.shared_object(cashier, false)?;
224 let dag = transaction.immutable_object(dag)?;
225 let execution = transaction.shared_object(execution, true)?;
226 let leader_registry = transaction.shared_root(&context.leader_registry, false)?;
227 let leader_cap = transaction.shared_object(leader_cap, false)?;
228 authorize(
229 transaction,
230 cashier,
231 dag,
232 execution,
233 leader_registry,
234 leader_cap,
235 target,
236 policy,
237 submission_gas_charge,
238 )?;
239 Ok(())
240 })
241}
242
243pub(crate) fn settle(
245 transaction: &mut move_boundary::NexusPtbBuilder,
246 dag: sui::types::Argument,
247 execution: sui::types::Argument,
248 vertex: &RuntimeVertex,
249 invocation: &sui::types::ObjectReference,
250) -> anyhow::Result<sui::types::Argument> {
251 let vertex = super::dag::runtime_vertex_arg(transaction, vertex)?;
252 let receiving = transaction.receiving_object::<Invocation>(invocation)?;
253 let runtime_authority = transaction.runtime_authority(false)?;
254 transaction.call_target(
255 scheduler_invocation_adapter_binding::settle_target,
256 vec![runtime_authority, dag, execution, vertex, receiving],
257 )
258}
259
260pub fn abort_expired_ptb(
265 context: &NexusContext,
266 dag: &sui::types::ObjectReference,
267 execution: &sui::types::ObjectReference,
268 vertex: &RuntimeVertex,
269 invocation: &sui::types::ObjectReference,
270 task_settlement: Option<&sui::types::ObjectReference>,
271) -> anyhow::Result<sui::types::ProgrammableTransaction> {
272 move_boundary::ptb(context, |transaction| {
273 let dag = transaction.immutable_object(dag)?;
274 let execution = transaction.shared_object(execution, true)?;
275 let vertex = super::dag::runtime_vertex_arg(transaction, vertex)?;
276 let receiving = transaction.receiving_object::<Invocation>(invocation)?;
277 let clock = transaction.clock()?;
278 let runtime_authority = transaction.runtime_authority(false)?;
279 transaction.call_target(
280 scheduler_invocation_adapter_binding::abort_expired_target,
281 vec![runtime_authority, dag, execution, vertex, receiving, clock],
282 )?;
283 if let Some(task) = task_settlement {
284 super::scheduler::append_settle_occurrence(transaction, task, execution, clock)?;
285 }
286 Ok(())
287 })
288}
289
290#[cfg(test)]
291mod tests {
292 use {
293 super::*,
294 crate::{
295 move_bindings::interface::graph::RuntimeVertex,
296 test_utils::sui_mocks::{mock_nexus_context, object_ref_for_id},
297 },
298 sui_sdk_types::{Argument, Command, Input},
299 };
300
301 fn vertex() -> RuntimeVertex {
302 RuntimeVertex::plain("tool")
303 }
304
305 #[test]
306 fn leader_authorization_calls_policy_before_lock_and_request() {
307 let context = mock_nexus_context();
308 let cashier = object_ref_for_id(sui::types::Address::from_static("0x81"));
309 let dag = object_ref_for_id(sui::types::Address::from_static("0x82"));
310 let execution = object_ref_for_id(sui::types::Address::from_static("0x83"));
311 let leader_cap = object_ref_for_id(sui::types::Address::from_static("0x84"));
312 let policy = InvocationPolicyCall::fixed_price(&context).unwrap();
313 let ptb = authorize_ptb(
314 &context,
315 &cashier,
316 &dag,
317 &execution,
318 &leader_cap,
319 InvocationTarget {
320 walk_index: 0,
321 vertex: &vertex(),
322 },
323 &policy,
324 42,
325 )
326 .unwrap();
327
328 let Input::Shared(shared) = &ptb.inputs[0] else {
329 panic!("cashier must be a shared input")
330 };
331 assert!(
332 !shared.mutability().is_mutable(),
333 "Invocation admission must not mutate ToolCashier"
334 );
335 let Input::ImmutableOrOwned(dag_input) = &ptb.inputs[1] else {
336 panic!("finalized DAG must be an immutable input")
337 };
338 assert_eq!(dag_input.object_id(), dag.object_id());
339 let calls = ptb
340 .commands
341 .iter()
342 .filter_map(|command| match command {
343 Command::MoveCall(call) => Some((call.module.as_str(), call.function.as_str())),
344 _ => None,
345 })
346 .collect::<Vec<_>>();
347 let request = calls
348 .iter()
349 .position(|call| *call == ("invocation_adapter", "new_request"))
350 .unwrap();
351 let policy = calls
352 .iter()
353 .position(|call| *call == ("fixed_price", "get_invocation"))
354 .unwrap();
355 let lock_and_request = calls
356 .iter()
357 .position(|call| *call == ("invocation_adapter", "lock_and_request"))
358 .unwrap();
359 assert!(request < policy && policy < lock_and_request);
360
361 let lock_and_request = ptb
362 .commands
363 .iter()
364 .find_map(|command| match command {
365 Command::MoveCall(call)
366 if call.module.as_str() == "invocation_adapter"
367 && call.function.as_str() == "lock_and_request" =>
368 {
369 Some(call)
370 }
371 _ => None,
372 })
373 .expect("lock and request call");
374 assert_eq!(lock_and_request.arguments.len(), 10);
375 }
376
377 #[test]
378 fn leader_authorization_requires_capability_before_reimbursement_charge() {
379 let context = mock_nexus_context();
380 let cashier = object_ref_for_id(sui::types::Address::from_static("0x81"));
381 let dag = object_ref_for_id(sui::types::Address::from_static("0x82"));
382 let execution = object_ref_for_id(sui::types::Address::from_static("0x83"));
383 let leader_cap = object_ref_for_id(sui::types::Address::from_static("0x84"));
384 let policy = InvocationPolicyCall::fixed_price(&context).unwrap();
385 let ptb = authorize_ptb(
386 &context,
387 &cashier,
388 &dag,
389 &execution,
390 &leader_cap,
391 InvocationTarget {
392 walk_index: 0,
393 vertex: &vertex(),
394 },
395 &policy,
396 42,
397 )
398 .unwrap();
399
400 let call = ptb
401 .commands
402 .iter()
403 .find_map(|command| match command {
404 Command::MoveCall(call)
405 if call.module.as_str() == "invocation_adapter"
406 && call.function.as_str() == "lock_and_request" =>
407 {
408 Some(call)
409 }
410 _ => None,
411 })
412 .expect("leader reimbursement call");
413 assert_eq!(call.arguments.len(), 10);
414
415 let Argument::Input(leader_cap_input) = call.arguments[4] else {
416 panic!("leader capability must be an object input")
417 };
418 let Input::Shared(shared) = &ptb.inputs[usize::from(leader_cap_input)] else {
419 panic!("leader capability must preserve its consensus owner")
420 };
421 assert_eq!(shared.object_id(), *leader_cap.object_id());
422
423 let Argument::Input(gas_charge) = call.arguments[8] else {
424 panic!("gas charge must be a pure input")
425 };
426 let Input::Pure(gas_charge) = &ptb.inputs[usize::from(gas_charge)] else {
427 panic!("gas charge must be a pure input")
428 };
429 assert_eq!(
430 u64::from_le_bytes(gas_charge.as_slice().try_into().unwrap()),
431 42,
432 );
433 }
434
435 #[test]
436 fn policy_witness_selects_runtime_module() {
437 let context = mock_nexus_context();
438 let policy = TypeName::new("0x71::custom_terms::Policy");
439 assert_eq!(
440 policy_target(&context, &policy).unwrap(),
441 (
442 sui::types::Address::from_static("0x71"),
443 "custom_terms".to_owned()
444 )
445 );
446 }
447
448 #[test]
449 fn finite_credits_selects_one_mutable_shared_object() {
450 let context = mock_nexus_context();
451 let credits = sui::types::Address::from_static("0x91");
452 let initial_shared_version = 7;
453
454 let policy =
455 InvocationPolicyCall::finite_credits(&context, credits, initial_shared_version)
456 .unwrap();
457
458 assert!(policy.policy.as_str().ends_with("::finite_credits::Policy"));
459 assert_eq!(
460 policy.arguments,
461 vec![OnchainToolArgument::SharedObject {
462 object_id: credits,
463 initial_shared_version,
464 mutable: true,
465 }]
466 );
467 let origin = context
468 .type_origin(PackageRole::Tool, "finite_credits", "Policy")
469 .unwrap();
470 assert_eq!(
471 policy.policy.as_str(),
472 format!("{}::finite_credits::Policy", hex::encode(origin.as_bytes()))
473 );
474 }
475
476 #[test]
477 fn time_pass_selects_one_read_only_shared_object() {
478 let context = mock_nexus_context();
479 let pass = sui::types::Address::from_static("0x92");
480
481 let policy = InvocationPolicyCall::time_pass(&context, pass, 7).unwrap();
482
483 assert!(policy.policy.as_str().ends_with("::time_pass::Policy"));
484 assert_eq!(
485 policy.arguments,
486 vec![OnchainToolArgument::SharedObject {
487 object_id: pass,
488 initial_shared_version: 7,
489 mutable: false,
490 }]
491 );
492 }
493
494 #[test]
495 fn timeout_refund_settles_the_task_after_the_invocation() {
496 let context = mock_nexus_context();
497 let dag = object_ref_for_id(sui::types::Address::from_static("0x82"));
498 let execution = object_ref_for_id(sui::types::Address::from_static("0x83"));
499 let invocation = object_ref_for_id(sui::types::Address::from_static("0x84"));
500 let task = object_ref_for_id(sui::types::Address::from_static("0x85"));
501 let ptb = abort_expired_ptb(
502 &context,
503 &dag,
504 &execution,
505 &vertex(),
506 &invocation,
507 Some(&task),
508 )
509 .expect("ptb should build");
510 let Input::ImmutableOrOwned(dag_input) = &ptb.inputs[0] else {
511 panic!("finalized DAG must be an immutable input")
512 };
513 assert_eq!(dag_input.object_id(), dag.object_id());
514 let refund = ptb
515 .commands
516 .iter()
517 .position(|command| match command {
518 Command::MoveCall(call) => {
519 call.module.as_str() == "invocation_adapter"
520 && call.function.as_str() == "abort_expired"
521 }
522 _ => false,
523 })
524 .expect("Invocation timeout refund should be present");
525 let task = ptb
526 .commands
527 .iter()
528 .position(|command| match command {
529 Command::MoveCall(call) => {
530 call.module.as_str() == "scheduler" && call.function.as_str() == "settle"
531 }
532 _ => false,
533 })
534 .expect("Task settlement should be present");
535
536 assert!(refund < task);
537
538 let Command::MoveCall(task_settlement) = &ptb.commands[task] else {
539 panic!("expected task settlement Move call");
540 };
541 let Argument::Input(clock_index) = task_settlement.arguments[3] else {
542 panic!("expected Clock input argument");
543 };
544 let Input::Shared(clock) = &ptb.inputs[usize::from(clock_index)] else {
545 panic!("expected Clock shared object input");
546 };
547 assert_eq!(clock.object_id(), move_boundary::CLOCK_OBJECT_ID);
548 assert!(!clock.mutability().is_mutable());
549 }
550}