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::{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: ParamMap::new(),
277 args: ArgMap::new(),
278 };
279
280 let components: HashMap<String, Value> = self
281 .spec
282 .components
283 .as_ref()
284 .map(|c| c.schemas.clone())
285 .unwrap_or_default();
286
287 for party in self.spec.parties.keys() {
288 out.params.insert(party.to_lowercase(), ParamType::Address);
289 }
290
291 if let Some(env) = &self.spec.environment {
292 out.params.extend(schema::params_from_schema(env, &components));
293 }
294
295 out.params.extend(schema::params_from_schema(&tx.params, &components));
296
297 if let Some(profile) = profile {
298 if let Some(env) = profile.environment.as_object() {
299 let values = env.clone();
300 out.set_args(values);
301 }
302
303 for (key, value) in profile.parties.iter() {
304 out.set_arg(key, json!(value));
305 }
306 }
307
308 Ok(out)
309 }
310
311 /// Returns all transactions defined in the protocol.
312 ///
313 /// # Returns
314 ///
315 /// Returns a reference to the map of transaction names to their definitions.
316 pub fn txs(&self) -> &HashMap<String, spec::Transaction> {
317 &self.spec.transactions
318 }
319
320 /// Returns all parties defined in the protocol.
321 ///
322 /// # Returns
323 ///
324 /// Returns a reference to the map of party names to their definitions.
325 pub fn parties(&self) -> &HashMap<String, spec::Party> {
326 &self.spec.parties
327 }
328
329 /// Returns all profiles defined in the protocol.
330 pub fn profiles(&self) -> &HashMap<String, spec::Profile> {
331 &self.spec.profiles
332 }
333
334 /// Starts a [`Tx3ClientBuilder`] for this protocol. Configure TRP options,
335 /// optional profile selection, party bindings, and env overrides, then
336 /// call `build()` to obtain a [`crate::Tx3Client`].
337 pub fn client(self) -> crate::facade::Tx3ClientBuilder {
338 crate::facade::Tx3ClientBuilder::from_protocol(self)
339 }
340}
341
342/// Input query specification.
343///
344/// This type is currently a placeholder for future input query functionality.
345pub struct InputQuery {}
346
347/// Map of input queries.
348///
349/// Used to represent input queries for transaction resolution.
350pub type QueryMap = BTreeMap<String, InputQuery>;
351
352/// An active transaction invocation.
353///
354/// This structure represents a transaction that is being prepared for execution.
355/// It holds the transaction template (TIR), parameter definitions, and current
356/// argument values.
357///
358/// Use the builder methods (`with_arg`, `with_args`) to populate arguments,
359/// then convert to a TRP resolve request using `into_resolve_request`.
360///
361/// # Example
362///
363/// ```ignore
364/// use serde_json::json;
365/// use tx3_sdk::tii::Protocol;
366///
367/// let protocol = Protocol::from_file("protocol.tii")?;
368/// let invocation = protocol.invoke("transfer", None)?;
369///
370/// // Set arguments
371/// let invocation = invocation
372/// .with_arg("sender", json!("addr1..."))
373/// .with_arg("amount", json!(1000000));
374///
375/// // Check what's missing
376/// for (name, ty) in invocation.unspecified_params() {
377/// println!("Need: {} ({:?})", name, ty);
378/// }
379///
380/// // Convert to resolve request
381/// let resolve_params = invocation.into_resolve_request()?;
382/// ```
383#[derive(Debug, Clone)]
384pub struct Invocation {
385 tir: TirEnvelope,
386 params: ParamMap,
387 args: ArgMap,
388 // TODO: support explicit input specification
389 // input_override: HashMap<String, v1beta0::UtxoSet>,
390
391 // TODO: support explicit fee specification
392 // fee_override: Option<u64>,
393}
394
395impl Invocation {
396 /// Returns a reference to all parameters for this invocation.
397 ///
398 /// # Returns
399 ///
400 /// A reference to the map of parameter names to their types.
401 pub fn params(&mut self) -> &ParamMap {
402 &self.params
403 }
404
405 /// Returns an iterator over parameters that haven't been specified yet.
406 ///
407 /// This is useful for checking which required arguments are still missing
408 /// before submitting the transaction.
409 ///
410 /// # Returns
411 ///
412 /// An iterator over (name, type) pairs for unspecified parameters.
413 pub fn unspecified_params(&mut self) -> impl Iterator<Item = (&String, &ParamType)> {
414 self.params
415 .iter()
416 .filter(|(k, _)| !self.args.contains_key(k.as_str()))
417 }
418
419 /// Sets a single argument value.
420 ///
421 /// # Arguments
422 ///
423 /// * `name` - The parameter name (case-insensitive)
424 /// * `value` - The JSON value to set
425 pub fn set_arg(&mut self, name: &str, value: serde_json::Value) {
426 self.args.insert(name.to_lowercase().to_string(), value);
427 }
428
429 /// Sets multiple argument values at once.
430 ///
431 /// # Arguments
432 ///
433 /// * `args` - A map of argument names to values
434 pub fn set_args(&mut self, args: ArgMap) {
435 self.args.extend(args);
436 }
437
438 /// Sets a single argument value (builder pattern).
439 ///
440 /// This is the builder-pattern variant of `set_arg`, allowing chained calls.
441 ///
442 /// # Arguments
443 ///
444 /// * `name` - The parameter name (case-insensitive)
445 /// * `value` - The JSON value to set
446 ///
447 /// # Returns
448 ///
449 /// Returns `self` for method chaining.
450 pub fn with_arg(mut self, name: &str, value: serde_json::Value) -> Self {
451 self.args.insert(name.to_lowercase().to_string(), value);
452 self
453 }
454
455 /// Sets multiple argument values at once (builder pattern).
456 ///
457 /// This is the builder-pattern variant of `set_args`, allowing chained calls.
458 ///
459 /// # Arguments
460 ///
461 /// * `args` - A map of argument names to values
462 ///
463 /// # Returns
464 ///
465 /// Returns `self` for method chaining.
466 pub fn with_args(mut self, args: ArgMap) -> Self {
467 self.args.extend(args);
468 self
469 }
470
471 /// Converts this invocation into a TRP resolve request.
472 ///
473 /// This method consumes the invocation and creates the parameters needed
474 /// to call the TRP `resolve` method.
475 ///
476 /// # Returns
477 ///
478 /// Returns `ResolveParams` that can be passed to `trp::Client::resolve`.
479 ///
480 /// # Errors
481 ///
482 /// Currently this method always succeeds, but returns `Result` for future
483 /// compatibility.
484 pub fn into_resolve_request(self) -> Result<crate::trp::ResolveParams, Error> {
485 // Every arg is marshalled by its `.tii` `ParamType`: top-level scalars
486 // come back bare, aggregates tagged. An unmapped arg has no type, so it
487 // passes through untouched. Arg keys are lowercased on set while params
488 // keep their original case, so match case-insensitively.
489 let args = self
490 .args
491 .clone()
492 .into_iter()
493 .map(|(key, value)| match self
494 .params
495 .iter()
496 .find(|(name, _)| name.to_lowercase() == key)
497 {
498 Some((_, ty)) => Ok((key, encode::encode(ty, &value)?)),
499 None => Ok((key, value)),
500 })
501 .collect::<Result<_, Error>>()?;
502
503 let tir = self.tir.clone();
504
505 Ok(crate::trp::ResolveParams {
506 tir,
507 args,
508 // We're already merging env into params / args, no need to send it independently.
509 // Having both mechanism is a footgun. We should revisit either the TRP schema to
510 // remove the option or split how we send the env in the SDK.
511 env: None,
512 })
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use std::collections::HashSet;
519
520 use serde_json::json;
521
522 use super::*;
523
524 #[test]
525 fn happy_path_smoke_test() {
526 let manifest_dir = env!("CARGO_MANIFEST_DIR");
527 let tii = format!("{manifest_dir}/tests/fixtures/transfer.tii");
528
529 let protocol = Protocol::from_file(&tii).unwrap();
530
531 let invoke = protocol.invoke("transfer", Some("preprod")).unwrap();
532
533 let mut invoke = invoke
534 .with_arg("sender", json!("addr1abc"))
535 .with_arg("quantity", json!(100_000_000));
536
537 let all_params: HashSet<_> = invoke.params().keys().collect();
538
539 assert_eq!(all_params.len(), 5);
540 assert!(all_params.contains(&"sender".to_string()));
541 assert!(all_params.contains(&"middleman".to_string()));
542 assert!(all_params.contains(&"receiver".to_string()));
543 assert!(all_params.contains(&"tax".to_string()));
544 assert!(all_params.contains(&"quantity".to_string()));
545
546 let unspecified_params: HashSet<_> = invoke.unspecified_params().map(|(k, _)| k).collect();
547
548 assert_eq!(unspecified_params.len(), 2);
549 assert!(unspecified_params.contains(&"middleman".to_string()));
550 assert!(unspecified_params.contains(&"receiver".to_string()));
551
552 let tx = invoke.into_resolve_request().unwrap();
553
554 dbg!(&tx);
555 }
556
557 #[test]
558 fn invoke_interprets_complex_param_types() {
559 let manifest_dir = env!("CARGO_MANIFEST_DIR");
560 let tii = format!("{manifest_dir}/tests/fixtures/complex.tii");
561
562 let protocol = Protocol::from_file(&tii).unwrap();
563 let mut invoke = protocol.invoke("complex", None).unwrap();
564 let params = invoke.params();
565
566 // Primitives, unit, and core `$ref`s.
567 assert!(matches!(params["quantity"], ParamType::Integer));
568 assert!(matches!(params["flag"], ParamType::Boolean));
569 assert!(matches!(params["nothing"], ParamType::Unit));
570 assert!(matches!(params["recipient"], ParamType::Address));
571 assert!(matches!(params["source"], ParamType::UtxoRef));
572 assert!(matches!(params["bag"], ParamType::AnyAsset));
573
574 // Parties become addresses.
575 assert!(matches!(params["sender"], ParamType::Address));
576 assert!(matches!(params["receiver"], ParamType::Address));
577
578 // Compound kinds.
579 assert!(matches!(params["amounts"], ParamType::List(_)));
580 assert!(matches!(params["pair"], ParamType::Tuple(_)));
581 assert!(matches!(params["labels"], ParamType::Map(_)));
582
583 // `#/components/schemas/<Name>` refs resolve against the components table:
584 // a record (AssetClass) and a variant (Side). This exercises the
585 // `components` threading through `Protocol::invoke`.
586 match ¶ms["asset"] {
587 rec @ ParamType::Record(_) => assert!(matches!(rec.field("policy"), Some(ParamType::Bytes))),
588 other => panic!("expected asset record, got {other:?}"),
589 }
590 match ¶ms["side"] {
591 ParamType::Variant(cases) => assert!(!cases.is_empty()),
592 other => panic!("expected side variant, got {other:?}"),
593 }
594 }
595
596 #[test]
597 fn invoke_encodes_aggregate_arg_into_wire_form() {
598 // End-to-end through the path `cshell`/`trix invoke` take (`set_args` →
599 // `into_resolve_request`) on a real TII: the `meta` record serializes to
600 // the tagged form while scalars stay bare.
601 let manifest_dir = env!("CARGO_MANIFEST_DIR");
602 let tii = format!("{manifest_dir}/tests/fixtures/invoke.tii");
603
604 let protocol = Protocol::from_file(&tii).unwrap();
605 let invoke = protocol.invoke("transfer", None).unwrap().with_args(
606 serde_json::from_value(json!({
607 "sender": "addr_test1vqx…",
608 "receiver": "addr_test1vqyy…",
609 "quantity": 2_000_000,
610 "urgent": true,
611 "memo": "deadbeef",
612 "meta": { "tags": [1, 2, 3], "level": 7 }
613 }))
614 .unwrap(),
615 );
616
617 let request = invoke.into_resolve_request().unwrap();
618
619 // Fields are positional in declared order (tags, level) — `required`
620 // order, not alphabetical.
621 assert_eq!(
622 request.args["meta"],
623 json!({
624 "struct": {
625 "constructor": 0,
626 "fields": [
627 { "list": [{ "int": 1 }, { "int": 2 }, { "int": 3 }] },
628 { "int": 7 }
629 ]
630 }
631 })
632 );
633
634 // Scalars stay bare; the resolver coerces them via the flat type.
635 assert_eq!(request.args["quantity"], json!(2_000_000));
636 assert_eq!(request.args["urgent"], json!(true));
637 assert_eq!(request.args["memo"], json!("deadbeef"));
638 }
639}
640