1use {
4 crate::{
5 move_bindings::{
6 interface::{
7 agent::{SkillDagBinding, SkillRequirement},
8 payment::{PaymentSourceKind, SkillPaymentPolicy},
9 version::InterfaceVersion,
10 },
11 registry::agent_registry::{AgentRecord, DefaultDagExecutor, SkillRecord},
12 },
13 sui,
14 },
15 serde::{Deserialize, Serialize},
16 sha2::{Digest as _, Sha256},
17 std::{fmt, path::PathBuf},
18};
19
20pub type AgentId = sui::types::Address;
22
23pub type SkillId = u64;
25
26pub const fn skill_id(value: u64) -> SkillId {
27 value
28}
29pub const DEFAULT_PRIORITY_FEE_PERCENTAGE: u64 = 20;
30pub const MIN_PRIORITY_FEE_PERCENTAGE: u64 = 10;
31pub const MAX_PRIORITY_FEE_PERCENTAGE: u64 =
32 crate::move_bindings::protocol_limits::interface::payment::MAX_PRIORITY_FEE_PERCENTAGE;
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub struct PriorityPaymentBudgetInput {
37 pub max_budget_mist: u64,
38 pub priority_fee_percentage: Option<u64>,
39 pub gas_budget_mist: Option<u64>,
40}
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub struct PriorityPaymentBudgetQuote {
45 pub max_budget_mist: u64,
46 pub priority_fee_percentage: u64,
47 pub gas_budget_mist: u64,
48 pub priority_fee_reserve_mist: u64,
49 pub reserved_budget_mist: u64,
50}
51
52pub fn normalized_priority_fee_percentage(priority_fee_percentage: u64) -> Option<u64> {
54 (MIN_PRIORITY_FEE_PERCENTAGE..=MAX_PRIORITY_FEE_PERCENTAGE)
55 .contains(&priority_fee_percentage)
56 .then_some(priority_fee_percentage)
57}
58
59pub fn effective_priority_fee_percentage(
61 priority_fee_percentage: Option<u64>,
62) -> anyhow::Result<u64> {
63 match priority_fee_percentage {
64 None => Ok(DEFAULT_PRIORITY_FEE_PERCENTAGE),
65 Some(priority_fee_percentage) => normalized_priority_fee_percentage(priority_fee_percentage)
66 .ok_or_else(|| {
67 anyhow::anyhow!(
68 "priority fee percentage must be in {MIN_PRIORITY_FEE_PERCENTAGE}..={MAX_PRIORITY_FEE_PERCENTAGE}, got {priority_fee_percentage}"
69 )
70 }),
71 }
72}
73
74pub fn priority_fee_mist_for_gas_budget(
76 gas_budget_mist: u64,
77 priority_fee_percentage: u64,
78) -> anyhow::Result<u64> {
79 let priority_fee_mist = u128::from(gas_budget_mist)
80 .checked_mul(u128::from(priority_fee_percentage))
81 .map(|value| value / 100)
82 .ok_or_else(|| anyhow::anyhow!("priority fee calculation overflows u128"))?;
83 u64::try_from(priority_fee_mist)
84 .map_err(|_| anyhow::anyhow!("priority fee calculation overflows u64"))
85}
86
87pub fn gas_budget_mist_for_max_budget_mist(
89 max_budget_mist: u64,
90 priority_fee_percentage: u64,
91) -> anyhow::Result<u64> {
92 let numerator = u128::from(max_budget_mist)
93 .checked_mul(100)
94 .and_then(|value| value.checked_add(99))
95 .ok_or_else(|| anyhow::anyhow!("gas budget numerator overflows u128"))?;
96 let denominator = u128::from(priority_fee_percentage)
97 .checked_add(100)
98 .ok_or_else(|| anyhow::anyhow!("gas budget denominator overflows u128"))?;
99 u64::try_from(numerator / denominator)
100 .map_err(|_| anyhow::anyhow!("gas budget calculation overflows u64"))
101}
102
103pub fn quote_priority_payment_budget(
105 input: PriorityPaymentBudgetInput,
106) -> anyhow::Result<PriorityPaymentBudgetQuote> {
107 let priority_fee_percentage = effective_priority_fee_percentage(input.priority_fee_percentage)?;
108 let gas_budget_mist = match input.gas_budget_mist {
109 Some(gas_budget_mist) => gas_budget_mist,
110 None => {
111 gas_budget_mist_for_max_budget_mist(input.max_budget_mist, priority_fee_percentage)?
112 }
113 };
114
115 let priority_fee_reserve_mist =
116 priority_fee_mist_for_gas_budget(gas_budget_mist, priority_fee_percentage)?;
117 let reserved_budget_mist = gas_budget_mist
118 .checked_add(priority_fee_reserve_mist)
119 .ok_or_else(|| anyhow::anyhow!("priority payment total overflows u64"))?;
120 if reserved_budget_mist > input.max_budget_mist {
121 return Err(anyhow::anyhow!(
122 "gas budget {gas_budget_mist} MIST plus priority fee reserve {priority_fee_reserve_mist} MIST exceeds maximum budget {} MIST",
123 input.max_budget_mist
124 ));
125 }
126
127 Ok(PriorityPaymentBudgetQuote {
128 max_budget_mist: input.max_budget_mist,
129 priority_fee_percentage,
130 gas_budget_mist,
131 priority_fee_reserve_mist,
132 reserved_budget_mist,
133 })
134}
135
136#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
138pub struct SkillRevisionLookupKey {
139 pub agent_id: AgentId,
140 pub skill_id: SkillId,
141 pub interface_revision: InterfaceVersion,
142}
143
144impl fmt::Display for SkillRevisionLookupKey {
145 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146 write!(
147 f,
148 "{}:{}:{}",
149 self.agent_id, self.skill_id, self.interface_revision
150 )
151 }
152}
153
154#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
156pub struct WorksheetKey {
157 pub agent_id: AgentId,
158 pub skill_id: SkillId,
159}
160
161impl fmt::Display for WorksheetKey {
162 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163 write!(f, "{}:{}", self.agent_id, self.skill_id)
164 }
165}
166
167#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
169pub struct SkillRecordContext {
170 pub agent_id: AgentId,
171 pub skill_id: SkillId,
172 pub record: SkillRecord,
173}
174
175#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
177pub struct AgentRecordContext {
178 pub agent_id: AgentId,
179 pub record: AgentRecord,
180}
181
182impl AgentRecordContext {
183 pub fn active(&self) -> bool {
184 self.record.active
185 }
186}
187
188impl SkillRecordContext {
189 pub fn active(&self) -> bool {
190 self.record.active
191 }
192
193 pub fn dag_binding(&self) -> &SkillDagBinding {
194 &self.record.dag_binding
195 }
196
197 pub fn current_interface_revision(&self) -> InterfaceVersion {
198 self.record.current_interface_revision
199 }
200
201 pub fn requirements(&self) -> &SkillRequirement {
202 &self.record.requirements
203 }
204}
205
206#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
208pub struct DefaultDagExecutorTarget {
209 pub agent_id: AgentId,
210 pub skill_id: SkillId,
211}
212
213#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
215pub struct AgentRegistrySnapshot {
216 pub id: sui::types::Address,
217 pub agents: Vec<AgentRecordContext>,
218 pub skills: Vec<SkillRecordContext>,
219 #[serde(default)]
220 pub default_executor: Option<DefaultDagExecutor>,
221}
222
223impl AgentRegistrySnapshot {
224 pub fn skill_revision_records(&self) -> anyhow::Result<Vec<SkillRevisionContext>> {
226 self.skills
227 .iter()
228 .map(SkillRevisionContext::from_skill_record)
229 .map(|record| {
230 record.validate()?;
231 Ok(record)
232 })
233 .collect()
234 }
235
236 pub fn skill_revision_record(
237 &self,
238 key: SkillRevisionLookupKey,
239 ) -> anyhow::Result<SkillRevisionContext> {
240 let matches = self
241 .skill_revision_records()?
242 .into_iter()
243 .filter(|skill_revision| skill_revision.key == key)
244 .collect::<Vec<_>>();
245
246 match matches.as_slice() {
247 [] => anyhow::bail!("TAP skill revision not found for key {key}"),
248 [skill_revision] => Ok(skill_revision.clone()),
249 _ => anyhow::bail!("duplicate TAP skill revisions found for key {key}"),
250 }
251 }
252
253 pub fn active_skill_revision_record(
254 &self,
255 agent_id: AgentId,
256 skill_id: SkillId,
257 ) -> anyhow::Result<SkillRevisionContext> {
258 let skills = self
259 .skills
260 .iter()
261 .filter(|skill| {
262 skill.agent_id == agent_id && skill.skill_id == skill_id && skill.active()
263 })
264 .collect::<Vec<_>>();
265
266 let skill = match skills.as_slice() {
267 [] => {
268 return Err(
269 SkillRevisionLookupError::MissingActiveRevision { agent_id, skill_id }.into(),
270 )
271 }
272 [skill] if skill.active() => *skill,
273 [_] => {
274 return Err(
275 SkillRevisionLookupError::MissingActiveRevision { agent_id, skill_id }.into(),
276 )
277 }
278 _ => {
279 return Err(SkillRevisionLookupError::DuplicateActiveRevision {
280 agent_id,
281 skill_id,
282 count: skills.len(),
283 }
284 .into())
285 }
286 };
287
288 let skill_revision = SkillRevisionContext {
289 key: SkillRevisionLookupKey {
290 agent_id,
291 skill_id,
292 interface_revision: skill.current_interface_revision(),
293 },
294 requirements: skill.requirements().clone(),
295 };
296 skill_revision.validate()?;
297 Ok(skill_revision)
298 }
299
300 pub fn default_dag_executor(&self) -> anyhow::Result<&DefaultDagExecutor> {
301 self.default_executor
302 .as_ref()
303 .ok_or_else(|| anyhow::anyhow!("AgentRegistry missing default agent"))
304 }
305}
306
307#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
309pub struct SkillRevisionContext {
310 pub key: SkillRevisionLookupKey,
311 pub requirements: SkillRequirement,
312}
313
314impl SkillRevisionContext {
315 pub(crate) fn from_skill_record(skill: &SkillRecordContext) -> Self {
316 Self {
317 key: SkillRevisionLookupKey {
318 agent_id: skill.agent_id,
319 skill_id: skill.skill_id,
320 interface_revision: skill.current_interface_revision(),
321 },
322 requirements: skill.requirements().clone(),
323 }
324 }
325
326 pub fn worksheet_key(&self) -> WorksheetKey {
327 WorksheetKey {
328 agent_id: self.key.agent_id,
329 skill_id: self.key.skill_id,
330 }
331 }
332
333 pub fn validate(&self) -> Result<(), TapValidationError> {
334 validate_requirements(&self.requirements)?;
335
336 Ok(())
337 }
338}
339
340#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
342pub struct ActiveSkillExecutionTarget {
343 pub skill: SkillRecordContext,
344 pub skill_revision: SkillRevisionContext,
345}
346
347#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
349pub struct DefaultDagExecutorRecord {
350 pub target: DefaultDagExecutorTarget,
351 pub skill: SkillRecordContext,
352 pub skill_revision: SkillRevisionContext,
353}
354
355#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
357pub struct SkillConfig {
358 pub name: String,
359 pub dag_path: PathBuf,
360 pub requirements: SkillRequirement,
361 pub interface_revision: InterfaceVersion,
362}
363
364impl SkillConfig {
365 pub fn validate(&self) -> Result<(), TapValidationError> {
366 if self.name.trim().is_empty() {
367 return Err(TapValidationError::MissingSkillName);
368 }
369 if self.dag_path.as_os_str().is_empty() {
370 return Err(TapValidationError::MissingDagPath);
371 }
372
373 validate_requirements(&self.requirements)
374 }
375}
376
377#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
379pub struct TapPublishArtifact {
380 pub skill_name: String,
381 pub dag_id: sui::types::Address,
382 pub interface_revision: InterfaceVersion,
383 pub requirements: SkillRequirement,
384}
385
386impl TapPublishArtifact {
387 pub fn from_config(config: &SkillConfig, dag_id: sui::types::Address) -> anyhow::Result<Self> {
388 config.validate()?;
389
390 Ok(Self {
391 skill_name: config.name.clone(),
392 dag_id,
393 interface_revision: config.interface_revision,
394 requirements: config.requirements.clone(),
395 })
396 }
397}
398
399pub fn tap_input_commitment_from_dag_inputs<I, V, P>(inputs: I) -> Vec<u8>
400where
401 I: IntoIterator<Item = (V, P)>,
402 V: AsRef<str>,
403 P: AsRef<str>,
404{
405 let mut canonical_inputs = inputs
406 .into_iter()
407 .map(|(vertex, port)| (vertex.as_ref().to_string(), port.as_ref().to_string()))
408 .collect::<Vec<_>>();
409 canonical_inputs.sort();
410
411 let encoded =
412 bcs::to_bytes(&canonical_inputs).expect("canonical TAP DAG input pairs should encode");
413 Sha256::digest(encoded).to_vec()
414}
415
416#[derive(Clone, Debug, PartialEq, Eq)]
417pub enum TapValidationError {
418 MissingSkillName,
419 MissingDagPath,
420 MissingInputCommitment,
421 EmptyAuthorizedToolModule,
422 EmptyAuthorizedToolFunction,
423}
424
425impl fmt::Display for TapValidationError {
426 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
427 match self {
428 TapValidationError::MissingSkillName => write!(f, "skill name is required"),
429 TapValidationError::MissingDagPath => write!(f, "DAG path is required"),
430 TapValidationError::MissingInputCommitment => write!(f, "input commitment is required"),
431 TapValidationError::EmptyAuthorizedToolModule => {
432 write!(f, "authorized tool module is required")
433 }
434 TapValidationError::EmptyAuthorizedToolFunction => {
435 write!(f, "authorized tool function is required")
436 }
437 }
438 }
439}
440
441impl std::error::Error for TapValidationError {}
442
443#[derive(Clone, Debug, PartialEq, Eq)]
444pub enum SkillRevisionLookupError {
445 MissingActiveRevision {
446 agent_id: AgentId,
447 skill_id: SkillId,
448 },
449 DuplicateActiveRevision {
450 agent_id: AgentId,
451 skill_id: SkillId,
452 count: usize,
453 },
454 InvalidSkillRevision(TapValidationError),
455}
456
457impl fmt::Display for SkillRevisionLookupError {
458 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
459 match self {
460 SkillRevisionLookupError::MissingActiveRevision { agent_id, skill_id } => {
461 write!(f, "no active TAP skill revision for agent_id={agent_id}, skill_id={skill_id}")
462 }
463 SkillRevisionLookupError::DuplicateActiveRevision {
464 agent_id,
465 skill_id,
466 count,
467 } => write!(
468 f,
469 "expected one active TAP skill revision for agent_id={agent_id}, skill_id={skill_id}, found {count}"
470 ),
471 SkillRevisionLookupError::InvalidSkillRevision(error) => {
472 write!(f, "invalid TAP skill revision: {error}")
473 }
474 }
475 }
476}
477
478impl std::error::Error for SkillRevisionLookupError {}
479
480pub fn validate_requirements(requirements: &SkillRequirement) -> Result<(), TapValidationError> {
481 if requirements.input_commitment.is_empty() {
482 return Err(TapValidationError::MissingInputCommitment);
483 }
484 for tool in &requirements.fixed_tools {
485 if tool.tool_fqn_string().trim().is_empty() {
486 return Err(TapValidationError::EmptyAuthorizedToolModule);
487 }
488 }
489
490 Ok(())
491}
492
493pub fn validate_execution_payment_options(
494 agent_id: AgentId,
495 policy: &SkillPaymentPolicy,
496 payment_source: &[u8],
497 payment_max_budget_mist: u64,
498 payer: sui::types::Address,
499) -> anyhow::Result<()> {
500 match policy {
501 SkillPaymentPolicy::UserFunded => {
502 let expected = bcs::to_bytes(&PaymentSourceKind::user_funded(payer))?;
503 let source_is_valid =
504 payment_source.is_empty() || payment_source == expected.as_slice();
505 if !source_is_valid {
506 anyhow::bail!(
507 "standard TAP user-funded payment source must be empty or generated user-funded source BCS"
508 );
509 }
510 }
511 SkillPaymentPolicy::AgentFunded { max_budget_mist } => {
512 if payment_max_budget_mist == 0 || payment_max_budget_mist > *max_budget_mist {
513 anyhow::bail!(
514 "standard TAP agent-funded payment budget {} must be positive and no greater than skill policy max {}",
515 payment_max_budget_mist,
516 max_budget_mist
517 );
518 }
519 let expected = bcs::to_bytes(&PaymentSourceKind::agent_funded(agent_id))?;
520 let source_is_valid = payment_source == expected.as_slice();
521 if !source_is_valid {
522 anyhow::bail!(
523 "standard Talus agent-funded payment source must be generated agent-funded source BCS"
524 );
525 }
526 }
527 }
528
529 Ok(())
530}
531
532pub fn payment_source_from_address(address: sui::types::Address) -> anyhow::Result<Vec<u8>> {
533 Ok(bcs::to_bytes(&PaymentSourceKind::user_funded(address))?)
534}
535
536pub fn resolve_active_tap_skill_revision<'a>(
538 records: &'a [SkillRevisionContext],
539 skills: &[SkillRecordContext],
540 agent_id: AgentId,
541 skill_id: SkillId,
542) -> Result<&'a SkillRevisionContext, SkillRevisionLookupError> {
543 let skill_matches = skills
544 .iter()
545 .filter(|skill| skill.agent_id == agent_id && skill.skill_id == skill_id && skill.active())
546 .collect::<Vec<_>>();
547
548 let skill = match skill_matches.as_slice() {
549 [] => return Err(SkillRevisionLookupError::MissingActiveRevision { agent_id, skill_id }),
550 [skill] if skill.active() => *skill,
551 [_] => return Err(SkillRevisionLookupError::MissingActiveRevision { agent_id, skill_id }),
552 _ => {
553 return Err(SkillRevisionLookupError::DuplicateActiveRevision {
554 agent_id,
555 skill_id,
556 count: skill_matches.len(),
557 })
558 }
559 };
560
561 let active = records
562 .iter()
563 .filter(|record| {
564 record.key.agent_id == agent_id
565 && record.key.skill_id == skill_id
566 && record.key.interface_revision == skill.current_interface_revision()
567 })
568 .collect::<Vec<_>>();
569
570 match active.as_slice() {
571 [] => Err(SkillRevisionLookupError::MissingActiveRevision { agent_id, skill_id }),
572 [record] => {
573 record
574 .validate()
575 .map_err(SkillRevisionLookupError::InvalidSkillRevision)?;
576 Ok(record)
577 }
578 _ => Err(SkillRevisionLookupError::DuplicateActiveRevision {
579 agent_id,
580 skill_id,
581 count: active.len(),
582 }),
583 }
584}
585
586pub fn resolve_active_tap_skill_execution_target(
588 registry: &AgentRegistrySnapshot,
589 agent_id: AgentId,
590 skill_id: SkillId,
591) -> anyhow::Result<ActiveSkillExecutionTarget> {
592 let skill_matches = registry
593 .skills
594 .iter()
595 .filter(|skill| skill.agent_id == agent_id && skill.skill_id == skill_id && skill.active())
596 .collect::<Vec<_>>();
597
598 let skill = match skill_matches.as_slice() {
599 [] => anyhow::bail!(
600 "active TAP skill not found for agent {} skill {}",
601 agent_id,
602 skill_id
603 ),
604 [skill] => (*skill).clone(),
605 _ => anyhow::bail!(
606 "duplicate active TAP skills found for agent {} skill {}",
607 agent_id,
608 skill_id
609 ),
610 };
611
612 let skill_revision = registry.active_skill_revision_record(agent_id, skill_id)?;
613
614 Ok(ActiveSkillExecutionTarget {
615 skill,
616 skill_revision,
617 })
618}
619
620pub fn resolve_default_tap_dag_executor(
622 registry: &AgentRegistrySnapshot,
623) -> anyhow::Result<DefaultDagExecutorRecord> {
624 let default_executor = registry.default_dag_executor()?;
625 let target = default_executor.target();
626 let execution_target =
627 resolve_active_tap_skill_execution_target(registry, target.agent_id, target.skill_id)?;
628
629 if execution_target.skill.dag_binding() != &SkillDagBinding::RuntimeSelected {
630 anyhow::bail!(
631 "default agent skill {} for agent {} is not runtime-DAG selected",
632 target.skill_id,
633 target.agent_id
634 );
635 }
636
637 Ok(DefaultDagExecutorRecord {
638 target,
639 skill: execution_target.skill,
640 skill_revision: execution_target.skill_revision,
641 })
642}
643
644#[cfg(test)]
645mod tests {
646 use {
647 super::*,
648 crate::move_bindings::{
649 interface::{
650 agent::{Agent, SkillSchedulePolicy},
651 payment::ExecutionPaymentFinalState,
652 },
653 registry::agent_registry::AgentRegistryInnerV1,
654 sui_framework::table::Table as MoveTable,
655 },
656 std::str::FromStr,
657 };
658
659 fn addr(value: &str) -> sui::types::Address {
660 sui::types::Address::from_str(value).expect("valid address")
661 }
662
663 fn requirements() -> SkillRequirement {
664 SkillRequirement {
665 input_commitment: vec![1],
666 payment_policy: SkillPaymentPolicy::AgentFunded {
667 max_budget_mist: 100,
668 },
669 schedule_policy: SkillSchedulePolicy::default(),
670 fixed_tools: Vec::new(),
671 }
672 }
673
674 fn skill_revision(revision: u64) -> SkillRevisionContext {
675 SkillRevisionContext {
676 key: SkillRevisionLookupKey {
677 agent_id: addr("0xa"),
678 skill_id: 11,
679 interface_revision: InterfaceVersion::new(revision),
680 },
681 requirements: requirements(),
682 }
683 }
684
685 fn skill(active: bool, current_interface_revision: u64) -> SkillRecordContext {
686 SkillRecordContext {
687 agent_id: addr("0xa"),
688 skill_id: 11,
689 record: SkillRecord {
690 description: vec![3],
691 active,
692 dag_binding: SkillDagBinding::pinned(addr("0x44")),
693 requirements: requirements(),
694 current_interface_revision: InterfaceVersion::new(current_interface_revision),
695 },
696 }
697 }
698
699 fn registry_with_active_skill() -> AgentRegistrySnapshot {
700 AgentRegistrySnapshot {
701 id: addr("0xf"),
702 agents: Vec::new(),
703 skills: vec![skill(true, 2)],
704 default_executor: None,
705 }
706 }
707
708 #[test]
709 fn agent_record_context_preserves_the_dynamic_field_key() {
710 let context = AgentRecordContext {
711 agent_id: addr("0xa"),
712 record: AgentRecord {
713 active: true,
714 skills: MoveTable::new(addr("0x90"), 0),
715 },
716 };
717
718 assert_eq!(context.agent_id, addr("0xa"));
719 assert!(context.record.active);
720 }
721
722 #[test]
723 fn skill_revision_records_are_derived_from_current_skills() {
724 let registry = registry_with_active_skill();
725 let records = registry.skill_revision_records().expect("derived records");
726
727 assert_eq!(records.len(), 1);
728 assert_eq!(records[0].key.agent_id, addr("0xa"));
729 assert_eq!(records[0].key.skill_id, 11);
730 assert_eq!(records[0].key.interface_revision, InterfaceVersion::new(2));
731 }
732
733 #[test]
734 fn validate_rejects_missing_input_commitment() {
735 let mut requirements = requirements();
736 requirements.input_commitment.clear();
737
738 assert_eq!(
739 validate_requirements(&requirements),
740 Err(TapValidationError::MissingInputCommitment)
741 );
742 }
743
744 #[test]
745 fn active_resolution_requires_exactly_one_active_revision() {
746 let active = skill_revision(1);
747 let inactive = skill_revision(2);
748 let records = vec![active.clone(), inactive];
749 let skills = vec![skill(true, 1)];
750
751 let resolved = resolve_active_tap_skill_revision(
752 &records,
753 &skills,
754 active.key.agent_id,
755 active.key.skill_id,
756 )
757 .expect("one active skill revision");
758
759 assert_eq!(resolved.key.interface_revision, InterfaceVersion::new(1));
760
761 let duplicate = vec![skill_revision(1), skill_revision(1)];
762 assert!(matches!(
763 resolve_active_tap_skill_revision(&duplicate, &skills, addr("0xa"), 11),
764 Err(SkillRevisionLookupError::DuplicateActiveRevision { count: 2, .. })
765 ));
766 }
767
768 #[cfg(feature = "bcs")]
769 #[test]
770 fn agent_registry_object_bcs_decodes_without_inline_default_executor() {
771 let raw = AgentRegistryInnerV1::new(MoveTable::new(addr("0x90"), 0));
772 let bytes = bcs::to_bytes(&raw).expect("raw Move registry BCS should encode");
773 let decoded: AgentRegistryInnerV1 =
774 bcs::from_bytes(&bytes).expect("raw Move registry BCS should decode");
775
776 assert_eq!(decoded.agents.id(), addr("0x90"));
777 }
778
779 #[test]
780 fn publish_artifact_contains_skill_artifact_fields() {
781 let config = SkillConfig {
782 name: "weather".to_string(),
783 dag_path: PathBuf::from("skill.dag.json"),
784 requirements: requirements(),
785 interface_revision: InterfaceVersion::new(1),
786 };
787
788 let artifact =
789 TapPublishArtifact::from_config(&config, addr("0x8")).expect("valid artifact");
790
791 assert_eq!(artifact.dag_id, addr("0x8"));
792 assert_eq!(artifact.skill_name, "weather");
793 }
794
795 #[test]
796 fn publish_artifact_preserves_current_skill_inputs() {
797 let config = SkillConfig {
798 name: "weather".to_string(),
799 dag_path: PathBuf::from("skill.dag.json"),
800 requirements: requirements(),
801 interface_revision: InterfaceVersion::new(1),
802 };
803 let artifact =
804 TapPublishArtifact::from_config(&config, addr("0x8")).expect("valid artifact");
805
806 assert_eq!(artifact.interface_revision, InterfaceVersion::new(1));
807 assert_eq!(artifact.requirements, config.requirements);
808 }
809
810 #[test]
811 fn dag_input_commitment_is_order_independent() {
812 let first = tap_input_commitment_from_dag_inputs([
813 ("weather::V2", "city"),
814 ("weather::V1", "country"),
815 ]);
816 let second = tap_input_commitment_from_dag_inputs([
817 ("weather::V1", "country"),
818 ("weather::V2", "city"),
819 ]);
820
821 assert_eq!(first, second);
822 assert_eq!(first.len(), 32);
823 }
824
825 #[test]
826 fn validate_execution_payment_options_enforces_user_funded_policy() {
827 let agent = addr("0xa");
828 let payer = addr("0x1");
829 let explicit_source = payment_source_from_address(payer).expect("payer source");
830 let other_source = payment_source_from_address(addr("0x2")).expect("other source");
831 let policy = SkillPaymentPolicy::UserFunded;
832
833 validate_execution_payment_options(agent, &policy, &[], 100, payer)
834 .expect("implicit payer source");
835 validate_execution_payment_options(agent, &policy, &explicit_source, 100, payer)
836 .expect("explicit payer source");
837 assert!(
838 validate_execution_payment_options(agent, &policy, &other_source, 100, payer,).is_err()
839 );
840 }
841
842 #[test]
843 fn validate_execution_payment_options_enforces_source_modes() {
844 let agent = addr("0xa");
845 let payer = addr("0x1");
846 let user_funded_agent_source = payment_source_from_address(agent).expect("agent source");
847 let agent_source =
848 bcs::to_bytes(&PaymentSourceKind::agent_funded(agent)).expect("agent vault source");
849
850 let agent_funded = SkillPaymentPolicy::AgentFunded {
851 max_budget_mist: 100,
852 };
853 validate_execution_payment_options(agent, &agent_funded, &agent_source, 100, payer)
854 .expect("agent-funded source at policy cap");
855 assert!(
856 validate_execution_payment_options(agent, &agent_funded, &[], 100, payer,).is_err()
857 );
858 assert!(validate_execution_payment_options(
859 agent,
860 &agent_funded,
861 &user_funded_agent_source,
862 100,
863 payer,
864 )
865 .is_err());
866 assert!(validate_execution_payment_options(
867 agent,
868 &agent_funded,
869 &agent_source,
870 101,
871 payer,
872 )
873 .is_err());
874 }
875
876 #[test]
877 fn tap_generated_enums_bcs_roundtrip() {
878 for value in [
879 PaymentSourceKind::user_funded(addr("0x1")),
880 PaymentSourceKind::agent_funded(addr("0xa")),
881 ] {
882 assert_eq!(
883 bcs::from_bytes::<PaymentSourceKind>(&bcs::to_bytes(&value).unwrap()).unwrap(),
884 value
885 );
886 }
887
888 let payment_state = ExecutionPaymentFinalState::Accomplished;
889 assert_eq!(
890 bcs::from_bytes::<ExecutionPaymentFinalState>(&bcs::to_bytes(&payment_state).unwrap())
891 .unwrap(),
892 payment_state
893 );
894 }
895
896 #[test]
897 fn tap_payment_source_bcs_roundtrips_and_rejects_unknown_kind() {
898 let invoker = addr("0x21");
899 let typed = bcs::from_bytes::<PaymentSourceKind>(
900 &payment_source_from_address(invoker).expect("typed invoker source"),
901 )
902 .expect("typed invoker source decodes");
903 assert_eq!(typed, PaymentSourceKind::user_funded(invoker));
904 assert_eq!(typed.identity(), invoker);
905
906 let agent = addr("0x22");
907 let typed = bcs::from_bytes::<PaymentSourceKind>(
908 &bcs::to_bytes(&PaymentSourceKind::agent_funded(agent)).expect("typed vault source"),
909 )
910 .expect("typed vault source decodes");
911 assert_eq!(typed, PaymentSourceKind::agent_funded(agent));
912 assert_eq!(typed.identity(), agent);
913
914 assert!(bcs::from_bytes::<PaymentSourceKind>(&[9]).is_err());
915 }
916
917 #[test]
918 fn priority_budget_quote_derives_maximal_gas_with_default_percentage() {
919 let quote = quote_priority_payment_budget(PriorityPaymentBudgetInput {
920 max_budget_mist: 120,
921 priority_fee_percentage: None,
922 gas_budget_mist: None,
923 })
924 .expect("quote should fit");
925
926 assert_eq!(quote.max_budget_mist, 120);
927 assert_eq!(
928 quote.priority_fee_percentage,
929 DEFAULT_PRIORITY_FEE_PERCENTAGE
930 );
931 assert_eq!(quote.gas_budget_mist, 100);
932 assert_eq!(quote.priority_fee_reserve_mist, 20);
933 assert_eq!(quote.reserved_budget_mist, 120);
934
935 let next_reserve = priority_fee_mist_for_gas_budget(
936 quote.gas_budget_mist + 1,
937 quote.priority_fee_percentage,
938 )
939 .unwrap();
940 assert!(quote.gas_budget_mist + 1 + next_reserve > 120);
941 }
942
943 #[test]
944 fn priority_budget_quote_preserves_explicit_gas_that_exactly_consumes_ceiling() {
945 let quote = quote_priority_payment_budget(PriorityPaymentBudgetInput {
946 max_budget_mist: 10_100,
947 priority_fee_percentage: Some(MAX_PRIORITY_FEE_PERCENTAGE),
948 gas_budget_mist: Some(100),
949 })
950 .expect("quote should fit");
951
952 assert_eq!(quote.gas_budget_mist, 100);
953 assert_eq!(quote.priority_fee_reserve_mist, 10_000);
954 assert_eq!(quote.reserved_budget_mist, 10_100);
955 }
956
957 #[test]
958 fn priority_budget_quote_preserves_explicit_gas_below_ceiling() {
959 let quote = quote_priority_payment_budget(PriorityPaymentBudgetInput {
960 max_budget_mist: 121,
961 priority_fee_percentage: Some(20),
962 gas_budget_mist: Some(100),
963 })
964 .expect("quote should fit below the ceiling");
965
966 assert_eq!(quote.gas_budget_mist, 100);
967 assert_eq!(quote.priority_fee_reserve_mist, 20);
968 assert_eq!(quote.reserved_budget_mist, 120);
969 }
970
971 #[test]
972 fn priority_budget_quote_rejects_explicit_gas_above_ceiling() {
973 let error = quote_priority_payment_budget(PriorityPaymentBudgetInput {
974 max_budget_mist: 119,
975 priority_fee_percentage: Some(20),
976 gas_budget_mist: Some(100),
977 })
978 .expect_err("gas plus reserve must fit the maximum budget");
979
980 assert!(error
981 .to_string()
982 .contains("exceeds maximum budget 119 MIST"));
983 }
984
985 #[test]
986 fn priority_budget_quote_uses_default_percentage_with_explicit_gas() {
987 let quote = quote_priority_payment_budget(PriorityPaymentBudgetInput {
988 max_budget_mist: 120,
989 priority_fee_percentage: None,
990 gas_budget_mist: Some(99),
991 })
992 .expect("default percentage should validate supplied gas");
993
994 assert_eq!(
995 quote.priority_fee_percentage,
996 DEFAULT_PRIORITY_FEE_PERCENTAGE
997 );
998 assert_eq!(quote.gas_budget_mist, 99);
999 assert_eq!(quote.priority_fee_reserve_mist, 19);
1000 assert_eq!(quote.reserved_budget_mist, 118);
1001 }
1002
1003 #[test]
1004 fn priority_budget_quote_derives_gas_with_explicit_percentage() {
1005 let quote = quote_priority_payment_budget(PriorityPaymentBudgetInput {
1006 max_budget_mist: 10_100,
1007 priority_fee_percentage: Some(MAX_PRIORITY_FEE_PERCENTAGE),
1008 gas_budget_mist: None,
1009 })
1010 .expect("explicit percentage should derive maximal gas");
1011
1012 assert_eq!(quote.gas_budget_mist, 100);
1013 assert_eq!(quote.priority_fee_reserve_mist, 10_000);
1014 assert_eq!(quote.reserved_budget_mist, 10_100);
1015 }
1016
1017 #[test]
1018 fn direct_gas_budget_formula_is_maximal_at_u64_boundary() {
1019 let max_budget_mist = u64::MAX;
1020 let priority_fee_percentage = MAX_PRIORITY_FEE_PERCENTAGE;
1021 let gas_budget_mist =
1022 gas_budget_mist_for_max_budget_mist(max_budget_mist, priority_fee_percentage)
1023 .expect("widened formula should support a maximum total budget");
1024 let priority_fee_reserve_mist =
1025 priority_fee_mist_for_gas_budget(gas_budget_mist, priority_fee_percentage).unwrap();
1026
1027 assert!(
1028 u128::from(gas_budget_mist) + u128::from(priority_fee_reserve_mist)
1029 <= u128::from(max_budget_mist)
1030 );
1031 let next_gas_budget_mist = gas_budget_mist + 1;
1032 let next_priority_fee_mist =
1033 priority_fee_mist_for_gas_budget(next_gas_budget_mist, priority_fee_percentage)
1034 .unwrap();
1035 assert!(
1036 u128::from(next_gas_budget_mist) + u128::from(next_priority_fee_mist)
1037 > u128::from(max_budget_mist)
1038 );
1039 }
1040
1041 #[test]
1042 fn priority_budget_quote_defaults_omission_and_rejects_invalid_priority() {
1043 assert_eq!(
1044 effective_priority_fee_percentage(None).expect("missing priority uses default"),
1045 DEFAULT_PRIORITY_FEE_PERCENTAGE
1046 );
1047 assert_eq!(
1048 effective_priority_fee_percentage(Some(MIN_PRIORITY_FEE_PERCENTAGE))
1049 .expect("minimum priority is valid"),
1050 MIN_PRIORITY_FEE_PERCENTAGE
1051 );
1052 assert_eq!(
1053 effective_priority_fee_percentage(Some(MAX_PRIORITY_FEE_PERCENTAGE))
1054 .expect("maximum priority is valid"),
1055 MAX_PRIORITY_FEE_PERCENTAGE
1056 );
1057
1058 let below_minimum = quote_priority_payment_budget(PriorityPaymentBudgetInput {
1059 max_budget_mist: 100,
1060 priority_fee_percentage: Some(9),
1061 gas_budget_mist: None,
1062 })
1063 .expect_err("explicit priority below the on-chain minimum must fail");
1064 assert!(below_minimum.to_string().contains("10..=10000"));
1065
1066 assert!(effective_priority_fee_percentage(Some(MAX_PRIORITY_FEE_PERCENTAGE + 1)).is_err());
1067 assert_eq!(
1068 normalized_priority_fee_percentage(MAX_PRIORITY_FEE_PERCENTAGE + 1),
1069 None
1070 );
1071 }
1072
1073 #[test]
1074 fn priority_fee_math_floors_and_rejects_overflow() {
1075 assert_eq!(priority_fee_mist_for_gas_budget(9, 10).unwrap(), 0);
1076 assert_eq!(priority_fee_mist_for_gas_budget(10, 10).unwrap(), 1);
1077 assert!(priority_fee_mist_for_gas_budget(u64::MAX, MAX_PRIORITY_FEE_PERCENTAGE).is_err());
1078 }
1079
1080 #[test]
1081 fn active_skill_execution_target_requires_one_active_skill_and_endpoint() {
1082 let registry = registry_with_active_skill();
1083 let resolved = resolve_active_tap_skill_execution_target(®istry, addr("0xa"), 11)
1084 .expect("active skill target");
1085
1086 assert_eq!(
1087 *resolved.skill.dag_binding(),
1088 SkillDagBinding::pinned(addr("0x44"))
1089 );
1090 assert_eq!(
1091 resolved.skill_revision.key.interface_revision,
1092 InterfaceVersion::new(2)
1093 );
1094 }
1095
1096 #[test]
1097 fn default_dag_executor_requires_runtime_selected_skill() {
1098 let mut registry = registry_with_active_skill();
1099 registry.default_executor = Some(DefaultDagExecutor {
1100 agent: Agent::from_anchor(addr("0xa")),
1101 skill_id: 11,
1102 });
1103
1104 let error = resolve_default_tap_dag_executor(®istry)
1105 .expect_err("pinned skill cannot be default runtime target");
1106 assert!(error.to_string().contains("is not runtime-DAG selected"));
1107
1108 registry.skills[0].record.dag_binding = SkillDagBinding::runtime_selected();
1109 let target = resolve_default_tap_dag_executor(®istry)
1110 .expect("runtime-selected default skill resolves");
1111
1112 assert_eq!(
1113 target.target,
1114 DefaultDagExecutorTarget {
1115 agent_id: addr("0xa"),
1116 skill_id: 11,
1117 }
1118 );
1119 assert_eq!(
1120 target.skill.dag_binding(),
1121 &SkillDagBinding::RuntimeSelected
1122 );
1123 }
1124
1125 #[test]
1126 fn active_skill_execution_target_rejects_missing_skill() {
1127 let mut registry = registry_with_active_skill();
1128 registry.skills.clear();
1129
1130 assert!(resolve_active_tap_skill_execution_target(®istry, addr("0xa"), 11).is_err());
1131 }
1132}