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 ¶ms["asset"] {
565 ParamType::Record(fields) => assert!(matches!(fields["policy"], ParamType::Bytes)),
566 other => panic!("expected asset record, got {other:?}"),
567 }
568 match ¶ms["side"] {
569 ParamType::Variant(cases) => assert!(!cases.is_empty()),
570 other => panic!("expected side variant, got {other:?}"),
571 }
572 }
573}
574