Skip to main content

nexus_sdk/transactions/
invocation.rs

1//! Exact Tool Invocation authorization and settlement transactions.
2
3use {
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/// One transient call to a Tool owner's Invocation policy.
21///
22/// Generated call targets cannot name owner policy modules selected at runtime,
23/// so this value carries the exact policy type and its additional arguments.
24/// The witness [TypeName] identifies the package and module. Every policy
25/// exposes `get_invocation`. Arguments contain only the policy specific values;
26/// the canonical cashier and request are inserted by the composer.
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct InvocationPolicyCall {
29    pub policy: TypeName,
30    pub arguments: Vec<OnchainToolArgument>,
31}
32
33/// Exact active walk position whose Tool Invocation is being authorized.
34#[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    /// Creates an arbitrary policy call from its witness [TypeName].
50    pub fn new(policy: TypeName, arguments: Vec<OnchainToolArgument>) -> Self {
51        Self { policy, arguments }
52    }
53
54    /// Selects the canonical fixed price policy for this Nexus deployment.
55    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    /// Selects the canonical finite credit policy with one mutable account.
63    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    /// Returns the canonical finite credits witness [TypeName].
79    pub fn finite_credits_policy(context: &NexusContext) -> anyhow::Result<TypeName> {
80        policy_type_name(context, "finite_credits")
81    }
82
83    /// Selects the canonical time pass policy with one read only shared account.
84    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/// Appends one [Invocation] admission authorized by a leader.
173///
174/// The capability guards admission. The verified gas charge reimburses that leader.
175#[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/// Builds one Tool [Invocation] admission authorized by a leader.
209///
210/// Sui checks capability control. The gas charge must be verified by the leader.
211#[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
243/// Appends settlement of one exact Invocation to an existing PTB.
244pub(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
260/// Builds a permissionless timeout refund for one exact [Invocation].
261///
262/// When `task_settlement` is supplied, the owning Task is settled only after
263/// the Invocation refund has removed its accounting lock.
264pub 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}