Skip to main content

tenzro_types/
skill.rs

1//! Skill registry types for Tenzro Network
2//!
3//! Defines types for the decentralized skills registry where
4//! agents and providers can publish callable skills for others to
5//! discover, invoke, and pay for autonomously.
6
7use crate::primitives::Address;
8use serde::{Deserialize, Serialize};
9
10/// Creator DID reserved for node-provided builtin skills and tools.
11/// Rows with this creator are registered by the node itself at boot:
12/// their liveness is the node's liveness, so the staleness sweeper
13/// exempts them, and the registration RPCs refuse third-party rows
14/// claiming this DID.
15pub const SYSTEM_CREATOR_DID: &str = "did:tenzro:system:tenzro-network";
16
17/// Status of a skill in the registry
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20#[derive(Default)]
21pub enum SkillStatus {
22    /// Skill is published and available for invocation
23    #[default]
24    Active,
25    /// Skill has been deactivated by its creator
26    Inactive,
27    /// Skill has been deprecated (superseded by a newer version)
28    Deprecated,
29}
30
31
32/// A callable skill published to the Tenzro Network skills registry
33///
34/// Skills are atomic, reusable capabilities that agents can discover
35/// and invoke autonomously. Each skill specifies its interface (input/output
36/// schemas), pricing, and the endpoint to call.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct SkillDefinition {
39    /// Unique skill identifier (UUID v4)
40    pub skill_id: String,
41
42    /// Human-readable skill name (e.g., "web-search", "code-review")
43    pub name: String,
44
45    /// Semantic version string (e.g., "1.0.0")
46    pub version: String,
47
48    /// DID of the agent or human who registered this skill
49    pub creator_did: String,
50
51    /// Payout wallet for the creator's share of paid invocations.
52    /// **Mandatory** for any non-zero `price_per_call`; registration
53    /// fails (`SkillError::MissingCreatorWallet`) if omitted for a
54    /// paid skill. Free skills (`price_per_call == 0`) may leave this
55    /// `None`.
56    pub creator_wallet: Option<Address>,
57
58    /// Description of what this skill does
59    pub description: String,
60
61    /// JSON Schema describing the expected input payload
62    pub input_schema: serde_json::Value,
63
64    /// JSON Schema describing the output payload
65    pub output_schema: serde_json::Value,
66
67    /// Price per invocation in TNZO atto-tokens (1 TNZO = 10^18 atto)
68    pub price_per_call: u128,
69
70    /// Discoverability tags (e.g., ["search", "web", "retrieval"])
71    pub tags: Vec<String>,
72
73    /// Required agent capabilities to invoke this skill
74    pub required_capabilities: Vec<String>,
75
76    /// Optional HTTP/RPC endpoint for remote invocation
77    /// If None, the skill is executed locally by the registered agent
78    pub endpoint: Option<String>,
79
80    /// Unix timestamp (seconds) when the skill was registered
81    pub created_at: u64,
82
83    /// Current status of the skill
84    pub status: SkillStatus,
85
86    /// Category for organization (e.g., "ai", "defi", "data", "general")
87    #[serde(default = "default_skill_category")]
88    pub category: String,
89
90    /// Number of times this skill has been invoked
91    pub invocation_count: u64,
92
93    /// Average rating (0–100, weighted by stake of raters)
94    pub rating: u8,
95
96    /// Unix timestamp (seconds) of the last liveness signal (registration,
97    /// invocation, or explicit `tenzro_heartbeatSkill` call). The liveness
98    /// sweeper uses this to flip `status` to `Inactive` once the skill goes
99    /// silent past the configured TTL, and eventually purges rows that stay
100    /// inactive past the purge window. Existing rows without this field
101    /// hydrate as "seen now" via the `default_last_seen` serde default —
102    /// stale entries surface only after they actually go silent post-upgrade.
103    #[serde(default = "default_last_seen")]
104    pub last_seen_at: u64,
105}
106
107fn default_skill_category() -> String {
108    "general".to_string()
109}
110
111fn default_last_seen() -> u64 {
112    std::time::SystemTime::now()
113        .duration_since(std::time::UNIX_EPOCH)
114        .unwrap_or_default()
115        .as_secs()
116}
117
118impl SkillDefinition {
119    /// Creates a new skill definition with default values
120    pub fn new(
121        name: String,
122        version: String,
123        creator_did: String,
124        description: String,
125        price_per_call: u128,
126    ) -> Self {
127        let skill_id = uuid::Uuid::new_v4().to_string();
128        let created_at = std::time::SystemTime::now()
129            .duration_since(std::time::UNIX_EPOCH)
130            .unwrap_or_default()
131            .as_secs();
132
133        Self {
134            skill_id,
135            name,
136            version,
137            creator_did,
138            creator_wallet: None,
139            description,
140            input_schema: serde_json::Value::Object(serde_json::Map::new()),
141            output_schema: serde_json::Value::Object(serde_json::Map::new()),
142            price_per_call,
143            tags: Vec::new(),
144            required_capabilities: Vec::new(),
145            endpoint: None,
146            created_at,
147            status: SkillStatus::Active,
148            category: default_skill_category(),
149            invocation_count: 0,
150            rating: 0,
151            last_seen_at: created_at,
152        }
153    }
154
155    /// Returns true if the skill is available for invocation
156    pub fn is_available(&self) -> bool {
157        self.status == SkillStatus::Active
158    }
159
160    /// Bumps `last_seen_at` to the current wall-clock time. Called by the
161    /// heartbeat RPC and by any successful invocation path that wants to
162    /// keep the skill from being swept as stale.
163    pub fn touch(&mut self) {
164        self.last_seen_at = default_last_seen();
165    }
166
167    /// Returns `true` when this skill is paid (non-zero `price_per_call`).
168    pub fn is_paid(&self) -> bool {
169        self.price_per_call > 0
170    }
171
172    /// Validate registration invariants. Any paid skill must declare a
173    /// `creator_wallet` to receive the creator share of each invocation;
174    /// otherwise the creator share would have no destination and the
175    /// network commission would have nothing to split against. Free
176    /// skills (`price_per_call == 0`) may omit `creator_wallet`.
177    pub fn validate_for_registration(&self) -> Result<(), &'static str> {
178        if self.is_paid() && self.creator_wallet.is_none() {
179            return Err("Paid skill (price_per_call > 0) requires a creator_wallet");
180        }
181        Ok(())
182    }
183}
184
185/// Filter parameters for listing and searching skills
186#[derive(Debug, Clone, Default, Serialize, Deserialize)]
187pub struct SkillFilter {
188    /// Filter by tag (must include this tag)
189    pub tag: Option<String>,
190
191    /// Filter by creator DID
192    pub creator_did: Option<String>,
193
194    /// Filter by required capability
195    pub capability: Option<String>,
196
197    /// Maximum price per call in atto-TNZO (inclusive)
198    pub max_price: Option<u128>,
199
200    /// Only return active skills
201    pub active_only: Option<bool>,
202
203    /// Free-text search in name and description
204    pub query: Option<String>,
205
206    /// Maximum number of results to return
207    pub limit: Option<usize>,
208
209    /// Pagination offset
210    pub offset: Option<usize>,
211}
212
213/// Result of using/invoking a skill
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct SkillInvocationResult {
216    /// The skill that was invoked
217    pub skill_id: String,
218
219    /// Invocation identifier for tracking
220    pub invocation_id: String,
221
222    /// The output payload returned by the skill
223    pub output: serde_json::Value,
224
225    /// Settlement transaction hash (if payment was made)
226    pub settlement_tx: Option<String>,
227
228    /// Amount paid in atto-TNZO
229    pub amount_paid: u128,
230
231    /// Unix timestamp when the invocation completed
232    pub completed_at: u64,
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_skill_definition_new() {
241        let skill = SkillDefinition::new(
242            "web-search".to_string(),
243            "1.0.0".to_string(),
244            "did:tenzro:human:test-123".to_string(),
245            "Searches the web and returns results".to_string(),
246            1_000_000_000_000_000_000, // 1 TNZO
247        );
248
249        assert!(!skill.skill_id.is_empty());
250        assert_eq!(skill.name, "web-search");
251        assert_eq!(skill.version, "1.0.0");
252        assert!(skill.is_available());
253        assert!(skill.is_paid());
254        assert!(skill.creator_wallet.is_none(), "wallet starts unset; caller must populate before paid registration");
255        assert_eq!(skill.status, SkillStatus::Active);
256        assert_eq!(skill.invocation_count, 0);
257    }
258
259    #[test]
260    fn paid_skill_without_wallet_fails_validation() {
261        let skill = SkillDefinition::new(
262            "premium-skill".to_string(),
263            "1.0.0".to_string(),
264            "did:tenzro:human:creator".to_string(),
265            "Paid skill".to_string(),
266            1_000_000_000_000_000_000, // 1 TNZO
267        );
268        // creator_wallet starts as None — validate should reject.
269        assert!(skill.validate_for_registration().is_err());
270    }
271
272    #[test]
273    fn free_skill_without_wallet_passes_validation() {
274        let skill = SkillDefinition::new(
275            "free-skill".to_string(),
276            "1.0.0".to_string(),
277            "did:tenzro:human:creator".to_string(),
278            "Free skill".to_string(),
279            0,
280        );
281        assert!(skill.validate_for_registration().is_ok());
282    }
283
284    #[test]
285    fn paid_skill_with_wallet_passes_validation() {
286        let mut skill = SkillDefinition::new(
287            "paid-skill".to_string(),
288            "1.0.0".to_string(),
289            "did:tenzro:human:creator".to_string(),
290            "Paid skill".to_string(),
291            500,
292        );
293        skill.creator_wallet = Some(Address::default());
294        assert!(skill.validate_for_registration().is_ok());
295    }
296
297    #[test]
298    fn test_skill_status_default() {
299        assert_eq!(SkillStatus::default(), SkillStatus::Active);
300    }
301
302    #[test]
303    fn test_skill_filter_default() {
304        let filter = SkillFilter::default();
305        assert!(filter.tag.is_none());
306        assert!(filter.creator_did.is_none());
307        assert!(filter.max_price.is_none());
308        assert!(filter.active_only.is_none());
309    }
310
311    #[test]
312    fn test_skill_serialization() {
313        let skill = SkillDefinition::new(
314            "code-review".to_string(),
315            "2.0.0".to_string(),
316            "did:tenzro:machine:agent-abc".to_string(),
317            "Reviews code and suggests improvements".to_string(),
318            500_000_000_000_000_000, // 0.5 TNZO
319        );
320
321        let json = serde_json::to_string(&skill).unwrap();
322        let deserialized: SkillDefinition = serde_json::from_str(&json).unwrap();
323        assert_eq!(skill.skill_id, deserialized.skill_id);
324        assert_eq!(skill.name, deserialized.name);
325        assert_eq!(skill.price_per_call, deserialized.price_per_call);
326    }
327}