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