Skip to main content

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    /// Returns all profiles defined in the protocol.
337    pub fn profiles(&self) -> &HashMap<String, spec::Profile> {
338        &self.spec.profiles
339    }
340
341    /// Starts a [`Tx3ClientBuilder`] for this protocol. Configure TRP options,
342    /// optional profile selection, party bindings, and env overrides, then
343    /// call `build()` to obtain a [`crate::Tx3Client`].
344    pub fn client(self) -> crate::facade::Tx3ClientBuilder {
345        crate::facade::Tx3ClientBuilder::from_protocol(self)
346    }
347}
348
349/// Type of a transaction parameter.
350///
351/// This enum represents the various types that transaction parameters can have,
352/// including primitives, complex types, and references to TX3 core types.
353#[derive(Debug, Clone)]
354pub enum ParamType {
355    /// Byte array type (hex-encoded).
356    Bytes,
357    /// Integer type (signed or unsigned).
358    Integer,
359    /// Boolean type.
360    Boolean,
361    /// UTXO reference in format `0x[64hex]#[index]`.
362    UtxoRef,
363    /// Bech32-encoded blockchain address.
364    Address,
365    /// List of another parameter type.
366    List(Box<ParamType>),
367    /// Custom JSON schema type.
368    Custom(Schema),
369}
370
371impl ParamType {
372    fn from_json_type(instance_type: InstanceType) -> Result<ParamType, Error> {
373        match instance_type {
374            InstanceType::Integer => Ok(ParamType::Integer),
375            InstanceType::Boolean => Ok(ParamType::Boolean),
376            _ => Err(Error::InvalidParamType),
377        }
378    }
379
380    /// Creates a parameter type from a JSON schema.
381    ///
382    /// This method interprets a JSON schema and converts it to the appropriate
383    /// `ParamType`. It handles TX3 core type references (Bytes, Address, UtxoRef)
384    /// as well as primitive types.
385    ///
386    /// # Arguments
387    ///
388    /// * `schema` - The JSON schema to convert
389    ///
390    /// # Returns
391    ///
392    /// Returns the corresponding `ParamType` on success.
393    ///
394    /// # Errors
395    ///
396    /// Returns an error if the schema cannot be mapped to a known parameter type.
397    pub fn from_json_schema(schema: Schema) -> Result<ParamType, Error> {
398        let as_object = schema.into_object();
399
400        if let Some(reference) = &as_object.reference {
401            return match reference.as_str() {
402                "https://tx3.land/specs/v1beta0/core#Bytes" => Ok(ParamType::Bytes),
403                "https://tx3.land/specs/v1beta0/core#Address" => Ok(ParamType::Address),
404                "https://tx3.land/specs/v1beta0/core#UtxoRef" => Ok(ParamType::UtxoRef),
405                _ => Err(Error::InvalidParamType),
406            };
407        }
408
409        if let Some(inner) = as_object.instance_type {
410            return match inner {
411                SingleOrVec::Single(x) => Self::from_json_type(*x),
412                SingleOrVec::Vec(_) => Err(Error::InvalidParamType),
413            };
414        }
415
416        Err(Error::InvalidParamType)
417    }
418}
419
420/// Input query specification.
421///
422/// This type is currently a placeholder for future input query functionality.
423pub struct InputQuery {}
424
425/// Map of parameter names to their types.
426///
427/// Used to represent the complete set of parameters required for a transaction.
428pub type ParamMap = HashMap<String, ParamType>;
429
430/// Map of input queries.
431///
432/// Used to represent input queries for transaction resolution.
433pub type QueryMap = BTreeMap<String, InputQuery>;
434
435/// An active transaction invocation.
436///
437/// This structure represents a transaction that is being prepared for execution.
438/// It holds the transaction template (TIR), parameter definitions, and current
439/// argument values.
440///
441/// Use the builder methods (`with_arg`, `with_args`) to populate arguments,
442/// then convert to a TRP resolve request using `into_resolve_request`.
443///
444/// # Example
445///
446/// ```ignore
447/// use serde_json::json;
448/// use tx3_sdk::tii::Protocol;
449///
450/// let protocol = Protocol::from_file("protocol.tii")?;
451/// let invocation = protocol.invoke("transfer", None)?;
452///
453/// // Set arguments
454/// let invocation = invocation
455///     .with_arg("sender", json!("addr1..."))
456///     .with_arg("amount", json!(1000000));
457///
458/// // Check what's missing
459/// for (name, ty) in invocation.unspecified_params() {
460///     println!("Need: {} ({:?})", name, ty);
461/// }
462///
463/// // Convert to resolve request
464/// let resolve_params = invocation.into_resolve_request()?;
465/// ```
466#[derive(Debug, Clone)]
467pub struct Invocation {
468    tir: TirEnvelope,
469    params: ParamMap,
470    args: ArgMap,
471    // TODO: support explicit input specification
472    // input_override: HashMap<String, v1beta0::UtxoSet>,
473
474    // TODO: support explicit fee specification
475    // fee_override: Option<u64>,
476}
477
478impl Invocation {
479    /// Returns a reference to all parameters for this invocation.
480    ///
481    /// # Returns
482    ///
483    /// A reference to the map of parameter names to their types.
484    pub fn params(&mut self) -> &ParamMap {
485        &self.params
486    }
487
488    /// Returns an iterator over parameters that haven't been specified yet.
489    ///
490    /// This is useful for checking which required arguments are still missing
491    /// before submitting the transaction.
492    ///
493    /// # Returns
494    ///
495    /// An iterator over (name, type) pairs for unspecified parameters.
496    pub fn unspecified_params(&mut self) -> impl Iterator<Item = (&String, &ParamType)> {
497        self.params
498            .iter()
499            .filter(|(k, _)| !self.args.contains_key(k.as_str()))
500    }
501
502    /// Sets a single argument value.
503    ///
504    /// # Arguments
505    ///
506    /// * `name` - The parameter name (case-insensitive)
507    /// * `value` - The JSON value to set
508    pub fn set_arg(&mut self, name: &str, value: serde_json::Value) {
509        self.args.insert(name.to_lowercase().to_string(), value);
510    }
511
512    /// Sets multiple argument values at once.
513    ///
514    /// # Arguments
515    ///
516    /// * `args` - A map of argument names to values
517    pub fn set_args(&mut self, args: ArgMap) {
518        self.args.extend(args);
519    }
520
521    /// Sets a single argument value (builder pattern).
522    ///
523    /// This is the builder-pattern variant of `set_arg`, allowing chained calls.
524    ///
525    /// # Arguments
526    ///
527    /// * `name` - The parameter name (case-insensitive)
528    /// * `value` - The JSON value to set
529    ///
530    /// # Returns
531    ///
532    /// Returns `self` for method chaining.
533    pub fn with_arg(mut self, name: &str, value: serde_json::Value) -> Self {
534        self.args.insert(name.to_lowercase().to_string(), value);
535        self
536    }
537
538    /// Sets multiple argument values at once (builder pattern).
539    ///
540    /// This is the builder-pattern variant of `set_args`, allowing chained calls.
541    ///
542    /// # Arguments
543    ///
544    /// * `args` - A map of argument names to values
545    ///
546    /// # Returns
547    ///
548    /// Returns `self` for method chaining.
549    pub fn with_args(mut self, args: ArgMap) -> Self {
550        self.args.extend(args);
551        self
552    }
553
554    /// Converts this invocation into a TRP resolve request.
555    ///
556    /// This method consumes the invocation and creates the parameters needed
557    /// to call the TRP `resolve` method.
558    ///
559    /// # Returns
560    ///
561    /// Returns `ResolveParams` that can be passed to `trp::Client::resolve`.
562    ///
563    /// # Errors
564    ///
565    /// Currently this method always succeeds, but returns `Result` for future
566    /// compatibility.
567    pub fn into_resolve_request(self) -> Result<crate::trp::ResolveParams, Error> {
568        let args = self.args.clone().into_iter().collect();
569
570        let tir = self.tir.clone();
571
572        Ok(crate::trp::ResolveParams {
573            tir,
574            args,
575            // We're already merging env into params / args, no need to send it independently.
576            // Having both mechanism is a footgun. We should revisit either the TRP schema to
577            // remove the option or split how we send the env in the SDK.
578            env: None,
579        })
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use std::collections::HashSet;
586
587    use serde_json::json;
588
589    use super::*;
590
591    #[test]
592    fn happy_path_smoke_test() {
593        let manifest_dir = env!("CARGO_MANIFEST_DIR");
594        let tii = format!("{manifest_dir}/tests/fixtures/transfer.tii");
595
596        let protocol = Protocol::from_file(&tii).unwrap();
597
598        let invoke = protocol.invoke("transfer", Some("preprod")).unwrap();
599
600        let mut invoke = invoke
601            .with_arg("sender", json!("addr1abc"))
602            .with_arg("quantity", json!(100_000_000));
603
604        let all_params: HashSet<_> = invoke.params().keys().collect();
605
606        assert_eq!(all_params.len(), 5);
607        assert!(all_params.contains(&"sender".to_string()));
608        assert!(all_params.contains(&"middleman".to_string()));
609        assert!(all_params.contains(&"receiver".to_string()));
610        assert!(all_params.contains(&"tax".to_string()));
611        assert!(all_params.contains(&"quantity".to_string()));
612
613        let unspecified_params: HashSet<_> = invoke.unspecified_params().map(|(k, _)| k).collect();
614
615        assert_eq!(unspecified_params.len(), 2);
616        assert!(unspecified_params.contains(&"middleman".to_string()));
617        assert!(unspecified_params.contains(&"receiver".to_string()));
618
619        let tx = invoke.into_resolve_request().unwrap();
620
621        dbg!(&tx);
622    }
623}