tx3_sdk/tii/mod.rs
1//! Transaction Invocation Interface (TII) for loading and interacting with TX3 protocols.
2//!
3//! This module provides tools for loading TX3 protocol definitions from TII files and
4//! invoking transactions with type-safe parameter handling.
5//!
6//! ## Overview
7//!
8//! The Transaction Invocation Interface (TII) is the bridge between TX3 protocol definitions
9//! and concrete transaction execution. A TII file (typically with `.tii` extension) is a JSON
10//! file that contains:
11//!
12//! - Protocol metadata (name, version, scope)
13//! - Transaction definitions with their TIR (Transaction Intermediate Representation)
14//! - Parameter schemas for each transaction
15//! - Party definitions
16//! - Environment profiles for different networks (mainnet, preview, etc.)
17//!
18//! ## Usage
19//!
20//! ### Loading a Protocol
21//!
22//! ```ignore
23//! use tx3_sdk::tii::Protocol;
24//!
25//! // Load from a file
26//! let protocol = Protocol::from_file("path/to/protocol.tii")?;
27//!
28//! // Or load from a string
29//! let protocol = Protocol::from_string(tii_json)?;
30//!
31//! // Or load from JSON value
32//! let protocol = Protocol::from_json(json_value)?;
33//! ```
34//!
35//! ### Invoking a Transaction
36//!
37//! ```ignore
38//! use serde_json::json;
39//! use tx3_sdk::tii::Protocol;
40//!
41//! let protocol = Protocol::from_file("protocol.tii")?;
42//!
43//! // Invoke with an optional profile
44//! let invocation = protocol.invoke("transfer", Some("preview"))?;
45//!
46//! // Set arguments using the builder pattern
47//! let invocation = invocation
48//! .with_arg("sender", json!("addr1..."))
49//! .with_arg("receiver", json!("addr1..."))
50//! .with_arg("amount", json!(1000000));
51//!
52//! // Check for unspecified required parameters
53//! for (name, param_type) in invocation.unspecified_params() {
54//! println!("Missing: {} (type: {:?})", name, param_type);
55//! }
56//!
57//! // Convert to TRP resolve request
58//! let resolve_params = invocation.into_resolve_request()?;
59//! ```
60//!
61//! ## Profiles
62//!
63//! Profiles allow you to pre-configure environment-specific values (addresses, constants, etc.)
64//! for different networks. When invoking a transaction with a profile, those values are
65//! automatically populated.
66
67use schemars::schema::{InstanceType, Schema, SingleOrVec};
68use serde::{Deserialize, Serialize};
69use serde_json::json;
70use std::collections::{BTreeMap, HashMap};
71use thiserror::Error;
72
73use crate::{
74 core::{ArgMap, TirEnvelope},
75 tii::spec::{Profile, Transaction},
76};
77
78pub mod spec;
79
80/// Error type for TII operations.
81///
82/// This enum represents all possible errors that can occur when loading
83/// and interacting with TX3 protocol definitions.
84#[derive(Debug, Error)]
85pub enum Error {
86 /// Invalid JSON in the TII file.
87 #[error("invalid TII JSON: {0}")]
88 InvalidJson(#[from] serde_json::Error),
89
90 /// Failed to read the TII file from disk.
91 #[error("failed to read file: {0}")]
92 IoError(#[from] std::io::Error),
93
94 /// Transaction name not found in the protocol.
95 #[error("unknown tx: {0}")]
96 UnknownTx(String),
97
98 /// Profile name not found in the protocol.
99 #[error("unknown profile: {0}")]
100 UnknownProfile(String),
101
102 /// Invalid JSON schema for transaction parameters.
103 #[error("invalid params schema")]
104 InvalidParamsSchema,
105
106 /// Invalid parameter type encountered in schema.
107 #[error("invalid param type")]
108 InvalidParamType,
109}
110
111fn params_from_schema(schema: Schema) -> Result<ParamMap, Error> {
112 let mut params = ParamMap::new();
113
114 let as_object = schema.into_object();
115
116 if let Some(obj_validation) = as_object.object {
117 for (key, value) in obj_validation.properties {
118 params.insert(key, ParamType::from_json_schema(value)?);
119 }
120 }
121
122 Ok(params)
123}
124
125/// A TX3 protocol loaded from a TII file.
126///
127/// This structure represents a loaded TX3 protocol definition and provides
128/// methods for inspecting transactions and creating invocations.
129///
130/// # Example
131///
132/// ```ignore
133/// use tx3_sdk::tii::Protocol;
134///
135/// let protocol = Protocol::from_file("protocol.tii")?;
136///
137/// // List all available transactions
138/// for (name, tx) in protocol.txs() {
139/// println!("Transaction: {}", name);
140/// }
141///
142/// // Invoke a specific transaction
143/// let invocation = protocol.invoke("transfer", Some("mainnet"))?;
144/// ```
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct Protocol {
147 spec: spec::TiiFile,
148}
149
150impl Protocol {
151 /// Creates a Protocol from a JSON value.
152 ///
153 /// # Arguments
154 ///
155 /// * `json` - A `serde_json::Value` containing the TII file content
156 ///
157 /// # Returns
158 ///
159 /// Returns a `Protocol` on success, or an error if the JSON is invalid.
160 ///
161 /// # Example
162 ///
163 /// ```ignore
164 /// use tx3_sdk::tii::Protocol;
165 /// use serde_json::json;
166 ///
167 /// let json = json!({
168 /// "tii": { "version": "1.0.0" },
169 /// "protocol": { "name": "MyProtocol", "version": "1.0.0" },
170 /// "transactions": {}
171 /// });
172 ///
173 /// let protocol = Protocol::from_json(json)?;
174 /// ```
175 pub fn from_json(json: serde_json::Value) -> Result<Protocol, Error> {
176 let spec = serde_json::from_value(json)?;
177
178 Ok(Protocol { spec })
179 }
180
181 /// Creates a Protocol from a JSON string.
182 ///
183 /// # Arguments
184 ///
185 /// * `code` - A string containing the TII JSON content
186 ///
187 /// # Returns
188 ///
189 /// Returns a `Protocol` on success, or an error if the JSON is invalid.
190 ///
191 /// # Example
192 ///
193 /// ```ignore
194 /// use tx3_sdk::tii::Protocol;
195 ///
196 /// let tii_content = r#"{
197 /// "tii": { "version": "1.0.0" },
198 /// "protocol": { "name": "MyProtocol", "version": "1.0.0" },
199 /// "transactions": {}
200 /// }"#;
201 ///
202 /// let protocol = Protocol::from_string(tii_content.to_string())?;
203 /// ```
204 pub fn from_string(code: String) -> Result<Protocol, Error> {
205 let json = serde_json::from_str(&code)?;
206 Self::from_json(json)
207 }
208
209 /// Creates a Protocol from a file path.
210 ///
211 /// # Arguments
212 ///
213 /// * `path` - Path to the TII file
214 ///
215 /// # Returns
216 ///
217 /// Returns a `Protocol` on success, or an error if the file cannot be read
218 /// or the JSON is invalid.
219 ///
220 /// # Example
221 ///
222 /// ```ignore
223 /// use tx3_sdk::tii::Protocol;
224 ///
225 /// let protocol = Protocol::from_file("./my_protocol.tii")?;
226 /// ```
227 pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Protocol, Error> {
228 let code = std::fs::read_to_string(path)?;
229 Self::from_string(code)
230 }
231
232 fn ensure_tx(&self, key: &str) -> Result<&Transaction, Error> {
233 let tx = self.spec.transactions.get(key);
234 let tx = tx.ok_or(Error::UnknownTx(key.to_string()))?;
235
236 Ok(tx)
237 }
238
239 fn ensure_profile(&self, key: &str) -> Result<&Profile, Error> {
240 let env = self
241 .spec
242 .profiles
243 .get(key)
244 .ok_or_else(|| Error::UnknownProfile(key.to_string()))?;
245
246 Ok(env)
247 }
248
249 /// Creates an invocation for a transaction.
250 ///
251 /// This method initializes an invocation for the specified transaction,
252 /// optionally applying a profile to pre-populate arguments.
253 ///
254 /// # Arguments
255 ///
256 /// * `tx` - The name of the transaction to invoke
257 /// * `profile` - Optional profile name to apply (e.g., "mainnet", "preview")
258 ///
259 /// # Returns
260 ///
261 /// Returns an `Invocation` that can be configured with arguments and
262 /// converted to a TRP resolve request.
263 ///
264 /// # Errors
265 ///
266 /// Returns an error if:
267 /// - The transaction name is not found
268 /// - The profile name is not found (if specified)
269 ///
270 /// # Example
271 ///
272 /// ```ignore
273 /// use tx3_sdk::tii::Protocol;
274 ///
275 /// let protocol = Protocol::from_file("protocol.tii")?;
276 ///
277 /// // Invoke with a profile
278 /// let invocation = protocol.invoke("transfer", Some("mainnet"))?;
279 ///
280 /// // Invoke without a profile
281 /// let invocation = protocol.invoke("transfer", None)?;
282 /// ```
283 pub fn invoke(&self, tx: &str, profile: Option<&str>) -> Result<Invocation, Error> {
284 let tx = self.ensure_tx(tx)?;
285
286 let profile = profile.map(|x| self.ensure_profile(x)).transpose()?;
287
288 let mut out = Invocation {
289 tir: tx.tir.clone(),
290 params: ParamMap::new(),
291 args: ArgMap::new(),
292 };
293
294 for party in self.spec.parties.keys() {
295 out.params.insert(party.to_lowercase(), ParamType::Address);
296 }
297
298 if let Some(env) = &self.spec.environment {
299 out.params.extend(params_from_schema(env.clone())?);
300 }
301
302 out.params.extend(params_from_schema(tx.params.clone())?);
303
304 if let Some(profile) = profile {
305 if let Some(env) = profile.environment.as_object() {
306 let values = env.clone();
307 out.set_args(values);
308 }
309
310 for (key, value) in profile.parties.iter() {
311 out.set_arg(key, json!(value));
312 }
313 }
314
315 Ok(out)
316 }
317
318 /// Returns all transactions defined in the protocol.
319 ///
320 /// # Returns
321 ///
322 /// Returns a reference to the map of transaction names to their definitions.
323 pub fn txs(&self) -> &HashMap<String, spec::Transaction> {
324 &self.spec.transactions
325 }
326
327 /// Returns all parties defined in the protocol.
328 ///
329 /// # Returns
330 ///
331 /// Returns a reference to the map of party names to their definitions.
332 pub fn parties(&self) -> &HashMap<String, spec::Party> {
333 &self.spec.parties
334 }
335}
336
337/// Type of a transaction parameter.
338///
339/// This enum represents the various types that transaction parameters can have,
340/// including primitives, complex types, and references to TX3 core types.
341#[derive(Debug, Clone)]
342pub enum ParamType {
343 /// Byte array type (hex-encoded).
344 Bytes,
345 /// Integer type (signed or unsigned).
346 Integer,
347 /// Boolean type.
348 Boolean,
349 /// UTXO reference in format `0x[64hex]#[index]`.
350 UtxoRef,
351 /// Bech32-encoded blockchain address.
352 Address,
353 /// List of another parameter type.
354 List(Box<ParamType>),
355 /// Custom JSON schema type.
356 Custom(Schema),
357}
358
359impl ParamType {
360 fn from_json_type(instance_type: InstanceType) -> Result<ParamType, Error> {
361 match instance_type {
362 InstanceType::Integer => Ok(ParamType::Integer),
363 InstanceType::Boolean => Ok(ParamType::Boolean),
364 _ => Err(Error::InvalidParamType),
365 }
366 }
367
368 /// Creates a parameter type from a JSON schema.
369 ///
370 /// This method interprets a JSON schema and converts it to the appropriate
371 /// `ParamType`. It handles TX3 core type references (Bytes, Address, UtxoRef)
372 /// as well as primitive types.
373 ///
374 /// # Arguments
375 ///
376 /// * `schema` - The JSON schema to convert
377 ///
378 /// # Returns
379 ///
380 /// Returns the corresponding `ParamType` on success.
381 ///
382 /// # Errors
383 ///
384 /// Returns an error if the schema cannot be mapped to a known parameter type.
385 pub fn from_json_schema(schema: Schema) -> Result<ParamType, Error> {
386 let as_object = schema.into_object();
387
388 if let Some(reference) = &as_object.reference {
389 return match reference.as_str() {
390 "https://tx3.land/specs/v1beta0/core#Bytes" => Ok(ParamType::Bytes),
391 "https://tx3.land/specs/v1beta0/core#Address" => Ok(ParamType::Address),
392 "https://tx3.land/specs/v1beta0/core#UtxoRef" => Ok(ParamType::UtxoRef),
393 _ => Err(Error::InvalidParamType),
394 };
395 }
396
397 if let Some(inner) = as_object.instance_type {
398 return match inner {
399 SingleOrVec::Single(x) => Self::from_json_type(*x),
400 SingleOrVec::Vec(_) => Err(Error::InvalidParamType),
401 };
402 }
403
404 Err(Error::InvalidParamType)
405 }
406}
407
408/// Input query specification.
409///
410/// This type is currently a placeholder for future input query functionality.
411pub struct InputQuery {}
412
413/// Map of parameter names to their types.
414///
415/// Used to represent the complete set of parameters required for a transaction.
416pub type ParamMap = HashMap<String, ParamType>;
417
418/// Map of input queries.
419///
420/// Used to represent input queries for transaction resolution.
421pub type QueryMap = BTreeMap<String, InputQuery>;
422
423/// An active transaction invocation.
424///
425/// This structure represents a transaction that is being prepared for execution.
426/// It holds the transaction template (TIR), parameter definitions, and current
427/// argument values.
428///
429/// Use the builder methods (`with_arg`, `with_args`) to populate arguments,
430/// then convert to a TRP resolve request using `into_resolve_request`.
431///
432/// # Example
433///
434/// ```ignore
435/// use serde_json::json;
436/// use tx3_sdk::tii::Protocol;
437///
438/// let protocol = Protocol::from_file("protocol.tii")?;
439/// let invocation = protocol.invoke("transfer", None)?;
440///
441/// // Set arguments
442/// let invocation = invocation
443/// .with_arg("sender", json!("addr1..."))
444/// .with_arg("amount", json!(1000000));
445///
446/// // Check what's missing
447/// for (name, ty) in invocation.unspecified_params() {
448/// println!("Need: {} ({:?})", name, ty);
449/// }
450///
451/// // Convert to resolve request
452/// let resolve_params = invocation.into_resolve_request()?;
453/// ```
454#[derive(Debug, Clone)]
455pub struct Invocation {
456 tir: TirEnvelope,
457 params: ParamMap,
458 args: ArgMap,
459 // TODO: support explicit input specification
460 // input_override: HashMap<String, v1beta0::UtxoSet>,
461
462 // TODO: support explicit fee specification
463 // fee_override: Option<u64>,
464}
465
466impl Invocation {
467 /// Returns a reference to all parameters for this invocation.
468 ///
469 /// # Returns
470 ///
471 /// A reference to the map of parameter names to their types.
472 pub fn params(&mut self) -> &ParamMap {
473 &self.params
474 }
475
476 /// Returns an iterator over parameters that haven't been specified yet.
477 ///
478 /// This is useful for checking which required arguments are still missing
479 /// before submitting the transaction.
480 ///
481 /// # Returns
482 ///
483 /// An iterator over (name, type) pairs for unspecified parameters.
484 pub fn unspecified_params(&mut self) -> impl Iterator<Item = (&String, &ParamType)> {
485 self.params
486 .iter()
487 .filter(|(k, _)| !self.args.contains_key(k.as_str()))
488 }
489
490 /// Sets a single argument value.
491 ///
492 /// # Arguments
493 ///
494 /// * `name` - The parameter name (case-insensitive)
495 /// * `value` - The JSON value to set
496 pub fn set_arg(&mut self, name: &str, value: serde_json::Value) {
497 self.args.insert(name.to_lowercase().to_string(), value);
498 }
499
500 /// Sets multiple argument values at once.
501 ///
502 /// # Arguments
503 ///
504 /// * `args` - A map of argument names to values
505 pub fn set_args(&mut self, args: ArgMap) {
506 self.args.extend(args);
507 }
508
509 /// Sets a single argument value (builder pattern).
510 ///
511 /// This is the builder-pattern variant of `set_arg`, allowing chained calls.
512 ///
513 /// # Arguments
514 ///
515 /// * `name` - The parameter name (case-insensitive)
516 /// * `value` - The JSON value to set
517 ///
518 /// # Returns
519 ///
520 /// Returns `self` for method chaining.
521 pub fn with_arg(mut self, name: &str, value: serde_json::Value) -> Self {
522 self.args.insert(name.to_lowercase().to_string(), value);
523 self
524 }
525
526 /// Sets multiple argument values at once (builder pattern).
527 ///
528 /// This is the builder-pattern variant of `set_args`, allowing chained calls.
529 ///
530 /// # Arguments
531 ///
532 /// * `args` - A map of argument names to values
533 ///
534 /// # Returns
535 ///
536 /// Returns `self` for method chaining.
537 pub fn with_args(mut self, args: ArgMap) -> Self {
538 self.args.extend(args);
539 self
540 }
541
542 /// Converts this invocation into a TRP resolve request.
543 ///
544 /// This method consumes the invocation and creates the parameters needed
545 /// to call the TRP `resolve` method.
546 ///
547 /// # Returns
548 ///
549 /// Returns `ResolveParams` that can be passed to `trp::Client::resolve`.
550 ///
551 /// # Errors
552 ///
553 /// Currently this method always succeeds, but returns `Result` for future
554 /// compatibility.
555 pub fn into_resolve_request(self) -> Result<crate::trp::ResolveParams, Error> {
556 let args = self.args.clone().into_iter().collect();
557
558 let tir = self.tir.clone();
559
560 Ok(crate::trp::ResolveParams {
561 tir,
562 args,
563 // We're already merging env into params / args, no need to send it independently.
564 // Having both mechanism is a footgun. We should revisit either the TRP schema to
565 // remove the option or split how we send the env in the SDK.
566 env: None,
567 })
568 }
569}
570
571#[cfg(test)]
572mod tests {
573 use std::collections::HashSet;
574
575 use serde_json::json;
576
577 use super::*;
578
579 #[test]
580 fn happy_path_smoke_test() {
581 let manifest_dir = env!("CARGO_MANIFEST_DIR");
582 let tii = format!("{manifest_dir}/tests/fixtures/transfer.tii");
583
584 let protocol = Protocol::from_file(&tii).unwrap();
585
586 let invoke = protocol.invoke("transfer", Some("preprod")).unwrap();
587
588 let mut invoke = invoke
589 .with_arg("sender", json!("addr1abc"))
590 .with_arg("quantity", json!(100_000_000));
591
592 let all_params: HashSet<_> = invoke.params().keys().collect();
593
594 assert_eq!(all_params.len(), 5);
595 assert!(all_params.contains(&"sender".to_string()));
596 assert!(all_params.contains(&"middleman".to_string()));
597 assert!(all_params.contains(&"receiver".to_string()));
598 assert!(all_params.contains(&"tax".to_string()));
599 assert!(all_params.contains(&"quantity".to_string()));
600
601 let unspecified_params: HashSet<_> = invoke.unspecified_params().map(|(k, _)| k).collect();
602
603 assert_eq!(unspecified_params.len(), 2);
604 assert!(unspecified_params.contains(&"middleman".to_string()));
605 assert!(unspecified_params.contains(&"receiver".to_string()));
606
607 let tx = invoke.into_resolve_request().unwrap();
608
609 dbg!(&tx);
610 }
611}