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 serde::{Deserialize, Serialize};
68use serde_json::{json, Value};
69use std::collections::{BTreeMap, HashMap};
70use thiserror::Error;
71
72use crate::{
73    core::{ArgMap, TirEnvelope},
74    tii::spec::{Profile, Transaction},
75};
76
77mod schema;
78pub mod spec;
79
80pub use schema::{ParamMap, ParamType, VariantCase};
81
82/// Error type for TII operations.
83///
84/// This enum represents all possible errors that can occur when loading
85/// and interacting with TX3 protocol definitions.
86#[derive(Debug, Error)]
87pub enum Error {
88    /// Invalid JSON in the TII file.
89    #[error("invalid TII JSON: {0}")]
90    InvalidJson(#[from] serde_json::Error),
91
92    /// Failed to read the TII file from disk.
93    #[error("failed to read file: {0}")]
94    IoError(#[from] std::io::Error),
95
96    /// Transaction name not found in the protocol.
97    #[error("unknown tx: {0}")]
98    UnknownTx(String),
99
100    /// Profile name not found in the protocol.
101    #[error("unknown profile: {0}")]
102    UnknownProfile(String),
103}
104
105/// A TX3 protocol loaded from a TII file.
106///
107/// This structure represents a loaded TX3 protocol definition and provides
108/// methods for inspecting transactions and creating invocations.
109///
110/// # Example
111///
112/// ```ignore
113/// use tx3_sdk::tii::Protocol;
114///
115/// let protocol = Protocol::from_file("protocol.tii")?;
116///
117/// // List all available transactions
118/// for (name, tx) in protocol.txs() {
119///     println!("Transaction: {}", name);
120/// }
121///
122/// // Invoke a specific transaction
123/// let invocation = protocol.invoke("transfer", Some("mainnet"))?;
124/// ```
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct Protocol {
127    spec: spec::TiiFile,
128}
129
130impl Protocol {
131    /// Creates a Protocol from a JSON value.
132    ///
133    /// # Arguments
134    ///
135    /// * `json` - A `serde_json::Value` containing the TII file content
136    ///
137    /// # Returns
138    ///
139    /// Returns a `Protocol` on success, or an error if the JSON is invalid.
140    ///
141    /// # Example
142    ///
143    /// ```ignore
144    /// use tx3_sdk::tii::Protocol;
145    /// use serde_json::json;
146    ///
147    /// let json = json!({
148    ///     "tii": { "version": "1.0.0" },
149    ///     "protocol": { "name": "MyProtocol", "version": "1.0.0" },
150    ///     "transactions": {}
151    /// });
152    ///
153    /// let protocol = Protocol::from_json(json)?;
154    /// ```
155    pub fn from_json(json: serde_json::Value) -> Result<Protocol, Error> {
156        let spec = serde_json::from_value(json)?;
157
158        Ok(Protocol { spec })
159    }
160
161    /// Creates a Protocol from a JSON string.
162    ///
163    /// # Arguments
164    ///
165    /// * `code` - A string containing the TII JSON content
166    ///
167    /// # Returns
168    ///
169    /// Returns a `Protocol` on success, or an error if the JSON is invalid.
170    ///
171    /// # Example
172    ///
173    /// ```ignore
174    /// use tx3_sdk::tii::Protocol;
175    ///
176    /// let tii_content = r#"{
177    ///     "tii": { "version": "1.0.0" },
178    ///     "protocol": { "name": "MyProtocol", "version": "1.0.0" },
179    ///     "transactions": {}
180    /// }"#;
181    ///
182    /// let protocol = Protocol::from_string(tii_content.to_string())?;
183    /// ```
184    pub fn from_string(code: String) -> Result<Protocol, Error> {
185        let json = serde_json::from_str(&code)?;
186        Self::from_json(json)
187    }
188
189    /// Creates a Protocol from a file path.
190    ///
191    /// # Arguments
192    ///
193    /// * `path` - Path to the TII file
194    ///
195    /// # Returns
196    ///
197    /// Returns a `Protocol` on success, or an error if the file cannot be read
198    /// or the JSON is invalid.
199    ///
200    /// # Example
201    ///
202    /// ```ignore
203    /// use tx3_sdk::tii::Protocol;
204    ///
205    /// let protocol = Protocol::from_file("./my_protocol.tii")?;
206    /// ```
207    pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Protocol, Error> {
208        let code = std::fs::read_to_string(path)?;
209        Self::from_string(code)
210    }
211
212    fn ensure_tx(&self, key: &str) -> Result<&Transaction, Error> {
213        let tx = self.spec.transactions.get(key);
214        let tx = tx.ok_or(Error::UnknownTx(key.to_string()))?;
215
216        Ok(tx)
217    }
218
219    fn ensure_profile(&self, key: &str) -> Result<&Profile, Error> {
220        let env = self
221            .spec
222            .profiles
223            .get(key)
224            .ok_or_else(|| Error::UnknownProfile(key.to_string()))?;
225
226        Ok(env)
227    }
228
229    /// Creates an invocation for a transaction.
230    ///
231    /// This method initializes an invocation for the specified transaction,
232    /// optionally applying a profile to pre-populate arguments.
233    ///
234    /// # Arguments
235    ///
236    /// * `tx` - The name of the transaction to invoke
237    /// * `profile` - Optional profile name to apply (e.g., "mainnet", "preview")
238    ///
239    /// # Returns
240    ///
241    /// Returns an `Invocation` that can be configured with arguments and
242    /// converted to a TRP resolve request.
243    ///
244    /// # Errors
245    ///
246    /// Returns an error if:
247    /// - The transaction name is not found
248    /// - The profile name is not found (if specified)
249    ///
250    /// # Example
251    ///
252    /// ```ignore
253    /// use tx3_sdk::tii::Protocol;
254    ///
255    /// let protocol = Protocol::from_file("protocol.tii")?;
256    ///
257    /// // Invoke with a profile
258    /// let invocation = protocol.invoke("transfer", Some("mainnet"))?;
259    ///
260    /// // Invoke without a profile
261    /// let invocation = protocol.invoke("transfer", None)?;
262    /// ```
263    pub fn invoke(&self, tx: &str, profile: Option<&str>) -> Result<Invocation, Error> {
264        let tx = self.ensure_tx(tx)?;
265
266        let profile = profile.map(|x| self.ensure_profile(x)).transpose()?;
267
268        let mut out = Invocation {
269            tir: tx.tir.clone(),
270            params: ParamMap::new(),
271            args: ArgMap::new(),
272        };
273
274        let components: HashMap<String, Value> = self
275            .spec
276            .components
277            .as_ref()
278            .map(|c| c.schemas.clone())
279            .unwrap_or_default();
280
281        for party in self.spec.parties.keys() {
282            out.params.insert(party.to_lowercase(), ParamType::Address);
283        }
284
285        if let Some(env) = &self.spec.environment {
286            out.params.extend(schema::params_from_schema(env, &components));
287        }
288
289        out.params.extend(schema::params_from_schema(&tx.params, &components));
290
291        if let Some(profile) = profile {
292            if let Some(env) = profile.environment.as_object() {
293                let values = env.clone();
294                out.set_args(values);
295            }
296
297            for (key, value) in profile.parties.iter() {
298                out.set_arg(key, json!(value));
299            }
300        }
301
302        Ok(out)
303    }
304
305    /// Returns all transactions defined in the protocol.
306    ///
307    /// # Returns
308    ///
309    /// Returns a reference to the map of transaction names to their definitions.
310    pub fn txs(&self) -> &HashMap<String, spec::Transaction> {
311        &self.spec.transactions
312    }
313
314    /// Returns all parties defined in the protocol.
315    ///
316    /// # Returns
317    ///
318    /// Returns a reference to the map of party names to their definitions.
319    pub fn parties(&self) -> &HashMap<String, spec::Party> {
320        &self.spec.parties
321    }
322
323    /// Returns all profiles defined in the protocol.
324    pub fn profiles(&self) -> &HashMap<String, spec::Profile> {
325        &self.spec.profiles
326    }
327
328    /// Starts a [`Tx3ClientBuilder`] for this protocol. Configure TRP options,
329    /// optional profile selection, party bindings, and env overrides, then
330    /// call `build()` to obtain a [`crate::Tx3Client`].
331    pub fn client(self) -> crate::facade::Tx3ClientBuilder {
332        crate::facade::Tx3ClientBuilder::from_protocol(self)
333    }
334}
335
336/// Input query specification.
337///
338/// This type is currently a placeholder for future input query functionality.
339pub struct InputQuery {}
340
341/// Map of input queries.
342///
343/// Used to represent input queries for transaction resolution.
344pub type QueryMap = BTreeMap<String, InputQuery>;
345
346/// An active transaction invocation.
347///
348/// This structure represents a transaction that is being prepared for execution.
349/// It holds the transaction template (TIR), parameter definitions, and current
350/// argument values.
351///
352/// Use the builder methods (`with_arg`, `with_args`) to populate arguments,
353/// then convert to a TRP resolve request using `into_resolve_request`.
354///
355/// # Example
356///
357/// ```ignore
358/// use serde_json::json;
359/// use tx3_sdk::tii::Protocol;
360///
361/// let protocol = Protocol::from_file("protocol.tii")?;
362/// let invocation = protocol.invoke("transfer", None)?;
363///
364/// // Set arguments
365/// let invocation = invocation
366///     .with_arg("sender", json!("addr1..."))
367///     .with_arg("amount", json!(1000000));
368///
369/// // Check what's missing
370/// for (name, ty) in invocation.unspecified_params() {
371///     println!("Need: {} ({:?})", name, ty);
372/// }
373///
374/// // Convert to resolve request
375/// let resolve_params = invocation.into_resolve_request()?;
376/// ```
377#[derive(Debug, Clone)]
378pub struct Invocation {
379    tir: TirEnvelope,
380    params: ParamMap,
381    args: ArgMap,
382    // TODO: support explicit input specification
383    // input_override: HashMap<String, v1beta0::UtxoSet>,
384
385    // TODO: support explicit fee specification
386    // fee_override: Option<u64>,
387}
388
389impl Invocation {
390    /// Returns a reference to all parameters for this invocation.
391    ///
392    /// # Returns
393    ///
394    /// A reference to the map of parameter names to their types.
395    pub fn params(&mut self) -> &ParamMap {
396        &self.params
397    }
398
399    /// Returns an iterator over parameters that haven't been specified yet.
400    ///
401    /// This is useful for checking which required arguments are still missing
402    /// before submitting the transaction.
403    ///
404    /// # Returns
405    ///
406    /// An iterator over (name, type) pairs for unspecified parameters.
407    pub fn unspecified_params(&mut self) -> impl Iterator<Item = (&String, &ParamType)> {
408        self.params
409            .iter()
410            .filter(|(k, _)| !self.args.contains_key(k.as_str()))
411    }
412
413    /// Sets a single argument value.
414    ///
415    /// # Arguments
416    ///
417    /// * `name` - The parameter name (case-insensitive)
418    /// * `value` - The JSON value to set
419    pub fn set_arg(&mut self, name: &str, value: serde_json::Value) {
420        self.args.insert(name.to_lowercase().to_string(), value);
421    }
422
423    /// Sets multiple argument values at once.
424    ///
425    /// # Arguments
426    ///
427    /// * `args` - A map of argument names to values
428    pub fn set_args(&mut self, args: ArgMap) {
429        self.args.extend(args);
430    }
431
432    /// Sets a single argument value (builder pattern).
433    ///
434    /// This is the builder-pattern variant of `set_arg`, allowing chained calls.
435    ///
436    /// # Arguments
437    ///
438    /// * `name` - The parameter name (case-insensitive)
439    /// * `value` - The JSON value to set
440    ///
441    /// # Returns
442    ///
443    /// Returns `self` for method chaining.
444    pub fn with_arg(mut self, name: &str, value: serde_json::Value) -> Self {
445        self.args.insert(name.to_lowercase().to_string(), value);
446        self
447    }
448
449    /// Sets multiple argument values at once (builder pattern).
450    ///
451    /// This is the builder-pattern variant of `set_args`, allowing chained calls.
452    ///
453    /// # Arguments
454    ///
455    /// * `args` - A map of argument names to values
456    ///
457    /// # Returns
458    ///
459    /// Returns `self` for method chaining.
460    pub fn with_args(mut self, args: ArgMap) -> Self {
461        self.args.extend(args);
462        self
463    }
464
465    /// Converts this invocation into a TRP resolve request.
466    ///
467    /// This method consumes the invocation and creates the parameters needed
468    /// to call the TRP `resolve` method.
469    ///
470    /// # Returns
471    ///
472    /// Returns `ResolveParams` that can be passed to `trp::Client::resolve`.
473    ///
474    /// # Errors
475    ///
476    /// Currently this method always succeeds, but returns `Result` for future
477    /// compatibility.
478    pub fn into_resolve_request(self) -> Result<crate::trp::ResolveParams, Error> {
479        let args = self.args.clone().into_iter().collect();
480
481        let tir = self.tir.clone();
482
483        Ok(crate::trp::ResolveParams {
484            tir,
485            args,
486            // We're already merging env into params / args, no need to send it independently.
487            // Having both mechanism is a footgun. We should revisit either the TRP schema to
488            // remove the option or split how we send the env in the SDK.
489            env: None,
490        })
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use std::collections::HashSet;
497
498    use serde_json::json;
499
500    use super::*;
501
502    #[test]
503    fn happy_path_smoke_test() {
504        let manifest_dir = env!("CARGO_MANIFEST_DIR");
505        let tii = format!("{manifest_dir}/tests/fixtures/transfer.tii");
506
507        let protocol = Protocol::from_file(&tii).unwrap();
508
509        let invoke = protocol.invoke("transfer", Some("preprod")).unwrap();
510
511        let mut invoke = invoke
512            .with_arg("sender", json!("addr1abc"))
513            .with_arg("quantity", json!(100_000_000));
514
515        let all_params: HashSet<_> = invoke.params().keys().collect();
516
517        assert_eq!(all_params.len(), 5);
518        assert!(all_params.contains(&"sender".to_string()));
519        assert!(all_params.contains(&"middleman".to_string()));
520        assert!(all_params.contains(&"receiver".to_string()));
521        assert!(all_params.contains(&"tax".to_string()));
522        assert!(all_params.contains(&"quantity".to_string()));
523
524        let unspecified_params: HashSet<_> = invoke.unspecified_params().map(|(k, _)| k).collect();
525
526        assert_eq!(unspecified_params.len(), 2);
527        assert!(unspecified_params.contains(&"middleman".to_string()));
528        assert!(unspecified_params.contains(&"receiver".to_string()));
529
530        let tx = invoke.into_resolve_request().unwrap();
531
532        dbg!(&tx);
533    }
534
535    #[test]
536    fn invoke_interprets_complex_param_types() {
537        let manifest_dir = env!("CARGO_MANIFEST_DIR");
538        let tii = format!("{manifest_dir}/tests/fixtures/complex.tii");
539
540        let protocol = Protocol::from_file(&tii).unwrap();
541        let mut invoke = protocol.invoke("complex", None).unwrap();
542        let params = invoke.params();
543
544        // Primitives, unit, and core `$ref`s.
545        assert!(matches!(params["quantity"], ParamType::Integer));
546        assert!(matches!(params["flag"], ParamType::Boolean));
547        assert!(matches!(params["nothing"], ParamType::Unit));
548        assert!(matches!(params["recipient"], ParamType::Address));
549        assert!(matches!(params["source"], ParamType::UtxoRef));
550        assert!(matches!(params["bag"], ParamType::AnyAsset));
551
552        // Parties become addresses.
553        assert!(matches!(params["sender"], ParamType::Address));
554        assert!(matches!(params["receiver"], ParamType::Address));
555
556        // Compound kinds.
557        assert!(matches!(params["amounts"], ParamType::List(_)));
558        assert!(matches!(params["pair"], ParamType::Tuple(_)));
559        assert!(matches!(params["labels"], ParamType::Map(_)));
560
561        // `#/components/schemas/<Name>` refs resolve against the components table:
562        // a record (AssetClass) and a variant (Side). This exercises the
563        // `components` threading through `Protocol::invoke`.
564        match &params["asset"] {
565            ParamType::Record(fields) => assert!(matches!(fields["policy"], ParamType::Bytes)),
566            other => panic!("expected asset record, got {other:?}"),
567        }
568        match &params["side"] {
569            ParamType::Variant(cases) => assert!(!cases.is_empty()),
570            other => panic!("expected side variant, got {other:?}"),
571        }
572    }
573}
574