Skip to main content

nexus_sdk/types/
tap.rs

1//! Standard TAP models shared by SDK, CLI, leader, and future Move surfaces.
2
3use {
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
20/// On-chain generated standard Talus agent ID.
21pub type AgentId = sui::types::Address;
22
23/// Agent-local standard TAP skill identity index.
24pub 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/// Inputs for validating or deriving a base gas budget within a total escrow amount.
35#[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/// Integer-only priority payment quote matching the on-chain floor arithmetic.
43#[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
52/// Returns the priority when it is inside the explicit on-chain range.
53pub 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
59/// Resolves an optional priority, defaulting omission to `20` and rejecting invalid explicit values.
60pub 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
74/// Computes `floor(gas_budget_mist * priority_fee_percentage / 100)` with widened arithmetic.
75pub 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
87/// Computes the maximal gas budget satisfying `gas + floor(gas * percentage / 100) <= max`.
88pub 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
103/// Validates an explicit gas budget or derives the maximal budget for the supplied total escrow.
104pub 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/// Key for a pinned skill interface revision.
137#[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/// Key for fresh worksheet and active-revision lookup.
155#[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/// Fetched skill record plus dynamic-table keys that are not stored in Move.
168#[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/// Fetched agent record plus its dynamic table key, which is not stored in Move.
176#[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/// SDK-resolved default DAG executor target for arbitrary-DAG execution.
207#[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/// Expanded `nexus_registry::agent_registry::AgentRegistry` contents with table entries fetched.
214#[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    /// Convert current skill revisions into leader-facing skill revision records.
225    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/// Active or pinned skill revision record returned to leader and SDK callers.
308#[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/// Registered skill plus the currently active skill revision used for fresh standard execution.
341#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
342pub struct ActiveSkillExecutionTarget {
343    pub skill: SkillRecordContext,
344    pub skill_revision: SkillRevisionContext,
345}
346
347/// Default execution target plus active skill revision recovered for fresh default DAG execution.
348#[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/// DAG-backed TAP skill config used by SDK/CLI authoring helpers.
356#[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/// Published TAP plus DAG artifact used when binding an agent skill.
378#[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
536/// Resolve exactly one active skill revision for fresh execution.
537pub 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
586/// Resolve the unique active skill and skill revision for fresh standard execution.
587pub 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
620/// Resolve the configured default agent from registry state.
621pub 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(&registry, 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(&registry)
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(&registry)
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(&registry, addr("0xa"), 11).is_err());
1131    }
1132}