Skip to main content

tx3_sdk/
facade.rs

1//! Ergonomic facade for the full TX3 lifecycle.
2//!
3//! This module provides a high-level API that covers invocation, resolution,
4//! signing, submission, and status polling.
5
6use std::collections::{HashMap, HashSet};
7use std::sync::Arc;
8use std::time::Duration;
9
10use serde::Deserialize;
11use serde_json::Value;
12use thiserror::Error;
13
14use crate::core::{ArgMap, BytesEnvelope, EnvMap, TirEnvelope};
15use crate::tii::{self, ParamMap, Protocol};
16use crate::trp::{self, ResolveParams, SubmitParams, TxStage, TxStatus, TxWitness};
17
18#[derive(Clone)]
19struct SignerParty {
20    name: String,
21    address: String,
22    signer: Arc<dyn Signer + Send + Sync>,
23}
24
25/// Error type for facade operations.
26#[derive(Debug, Error)]
27pub enum Error {
28    /// Error originating from TII operations.
29    #[error(transparent)]
30    Tii(#[from] crate::tii::Error),
31
32    /// Error originating from TRP operations.
33    #[error(transparent)]
34    Trp(#[from] crate::trp::Error),
35
36    /// A transaction name was not declared by the protocol.
37    #[error("unknown transaction: {0}")]
38    UnknownTx(String),
39
40    /// A profile name was not declared by the protocol.
41    #[error("unknown profile: {0}")]
42    UnknownProfile(String),
43
44    /// A party name was not declared by the protocol.
45    #[error("unknown party: {0}")]
46    UnknownParty(String),
47
48    /// The builder was finalized without a TRP endpoint.
49    #[error("TRP endpoint not configured")]
50    MissingTrpEndpoint,
51
52    /// Signer failed to produce a witness.
53    #[error("signer error: {0}")]
54    Signer(#[source] Box<dyn std::error::Error + Send + Sync>),
55
56    /// Submitted hash does not match the resolved hash.
57    #[error("submit hash mismatch: expected {expected}, got {received}")]
58    SubmitHashMismatch { expected: String, received: String },
59
60    /// Transaction failed to reach confirmation.
61    #[error("tx {hash} failed with stage {stage:?}")]
62    FinalizedFailed { hash: String, stage: TxStage },
63
64    /// Transaction did not reach confirmation within the polling window.
65    #[error("tx {hash} not confirmed after {attempts} attempts (delay {delay:?})")]
66    FinalizedTimeout {
67        hash: String,
68        attempts: u32,
69        delay: Duration,
70    },
71}
72
73/// Configuration for check-status polling.
74///
75/// Used by `wait_for_confirmed` and `wait_for_finalized`.
76#[derive(Debug, Clone)]
77pub struct PollConfig {
78    /// Number of attempts before timing out.
79    pub attempts: u32,
80    /// Delay between attempts.
81    pub delay: Duration,
82}
83
84impl Default for PollConfig {
85    fn default() -> Self {
86        Self {
87            attempts: 20,
88            delay: Duration::from_secs(5),
89        }
90    }
91}
92
93/// Inputs passed to a [`Signer`] for each sign call.
94///
95/// Carries both the bound tx hash and the full hex-encoded tx CBOR. Hash-based
96/// signers (Cardano, Ed25519) read `tx_hash_hex`; tx-based signers (e.g. wallet
97/// adapters that need the full tx body) read `tx_cbor_hex`. The SDK always
98/// populates both fields.
99#[derive(Debug, Clone)]
100pub struct SignRequest {
101    /// Hex-encoded tx hash bound to this signing call.
102    pub tx_hash_hex: String,
103    /// Hex-encoded full tx CBOR.
104    pub tx_cbor_hex: String,
105}
106
107/// A signer capable of producing TRP witnesses.
108///
109/// Signers are address-aware and must return the address they correspond to.
110pub trait Signer: Send + Sync {
111    /// Returns the address associated with this signer.
112    fn address(&self) -> &str;
113
114    /// Signs the transaction described by `request`.
115    fn sign(
116        &self,
117        request: &SignRequest,
118    ) -> Result<TxWitness, Box<dyn std::error::Error + Send + Sync>>;
119}
120
121/// A party referenced by the protocol.
122#[derive(Clone)]
123pub enum Party {
124    /// Read-only party with a known address.
125    Address(String),
126    /// Party capable of signing transactions.
127    Signer {
128        /// Party address (used for invocation args).
129        address: String,
130        /// Signer implementation.
131        signer: Arc<dyn Signer + Send + Sync>,
132    },
133}
134
135impl Party {
136    /// Creates a read-only party from an address.
137    pub fn address(address: impl Into<String>) -> Self {
138        Party::Address(address.into())
139    }
140
141    /// Creates a signer party from a signer.
142    ///
143    /// The party address is taken from the signer itself.
144    ///
145    /// # Example
146    ///
147    /// ```rust
148    /// use tx3_sdk::{CardanoSigner, Party};
149    ///
150    /// let signer = CardanoSigner::from_hex("addr_test1...", "deadbeef...")?;
151    /// let party = Party::signer(signer);
152    /// # Ok::<(), tx3_sdk::Error>(())
153    /// ```
154    pub fn signer(signer: impl Signer + 'static) -> Self {
155        Party::Signer {
156            address: signer.address().to_string(),
157            signer: Arc::new(signer),
158        }
159    }
160
161    fn address_value(&self) -> &str {
162        match self {
163            Party::Address(address) => address,
164            Party::Signer { address, .. } => address,
165        }
166    }
167
168    fn signer_party(&self, name: &str) -> Option<SignerParty> {
169        match self {
170            Party::Signer { address, signer } => Some(SignerParty {
171                name: name.to_string(),
172                address: address.clone(),
173                signer: Arc::clone(signer),
174            }),
175            _ => None,
176        }
177    }
178}
179
180/// A named profile baked into a client: environment values and party
181/// addresses keyed by name.
182///
183/// Produced either by deconstructing a loaded [`Protocol`] inside
184/// [`Tx3ClientBuilder::from_protocol`] or by parsing the per-profile JSON
185/// blob a generated codegen client embeds (via `serde_json::from_str` —
186/// `Profile` derives `Deserialize`).
187#[derive(Debug, Clone, Default, Deserialize)]
188pub struct Profile {
189    /// Environment values applied to every transaction under this profile.
190    #[serde(default)]
191    pub environment: EnvMap,
192    /// Party addresses applied to every transaction under this profile.
193    #[serde(default)]
194    pub parties: HashMap<String, String>,
195}
196
197/// High-level client over a TX3 protocol.
198///
199/// Holds the deconstructed protocol parts — per-transaction TIR envelopes,
200/// named profiles, the set of declared party names — plus the runtime state
201/// (TRP client, bound parties, selected profile and env overrides).
202///
203/// Construct one through [`Tx3ClientBuilder`], obtained via
204/// [`Protocol::client`]: profile selection and party/env binding happen on
205/// the builder, and `build()` performs all fallible validation.
206#[derive(Clone)]
207pub struct Tx3Client {
208    transactions: HashMap<String, TirEnvelope>,
209    tx_params: HashMap<String, ParamMap>,
210    known_parties: HashSet<String>,
211    trp: trp::Client,
212    bound_parties: HashMap<String, Party>,
213    selected_profile: Option<Profile>,
214    env_overrides: EnvMap,
215}
216
217impl Tx3Client {
218    /// Constructs a client from already-deconstructed protocol parts.
219    ///
220    /// Crate-internal entry used by [`Tx3ClientBuilder::build`]. External
221    /// callers go through the builder.
222    #[allow(clippy::too_many_arguments)]
223    pub(crate) fn from_parts(
224        transactions: HashMap<String, TirEnvelope>,
225        tx_params: HashMap<String, ParamMap>,
226        known_parties: HashSet<String>,
227        trp: trp::Client,
228        bound_parties: HashMap<String, Party>,
229        selected_profile: Option<Profile>,
230        env_overrides: EnvMap,
231    ) -> Self {
232        let known_parties = known_parties
233            .into_iter()
234            .map(|name| name.to_lowercase())
235            .collect();
236        Self {
237            transactions,
238            tx_params,
239            known_parties,
240            trp,
241            bound_parties,
242            selected_profile,
243            env_overrides,
244        }
245    }
246
247    /// Binds a party (signer or read-only address) by name after the client
248    /// has been built. Useful for late binding when, e.g., a user logs in
249    /// after the client is already in scope.
250    ///
251    /// Overrides any address the selected profile declared for the same name.
252    ///
253    /// # Errors
254    ///
255    /// Returns [`Error::UnknownParty`] if `name` is not a party declared by
256    /// the protocol.
257    pub fn with_party(mut self, name: impl Into<String>, party: Party) -> Result<Self, Error> {
258        let name = name.into().to_lowercase();
259        if !self.known_parties.contains(&name) {
260            return Err(Error::UnknownParty(name));
261        }
262        self.bound_parties.insert(name, party);
263        Ok(self)
264    }
265
266    /// Binds a party without validating the name against the protocol's
267    /// declared parties. Intended for codegen-generated wrappers — see
268    /// [`Tx3ClientBuilder::with_party_unchecked`]. Hand-written code SHOULD
269    /// use [`Tx3Client::with_party`].
270    pub fn with_party_unchecked(mut self, name: impl Into<String>, party: Party) -> Self {
271        self.bound_parties.insert(name.into().to_lowercase(), party);
272        self
273    }
274
275    /// Binds multiple parties at once. See [`Tx3Client::with_party`].
276    pub fn with_parties<I, K>(mut self, parties: I) -> Result<Self, Error>
277    where
278        I: IntoIterator<Item = (K, Party)>,
279        K: Into<String>,
280    {
281        for (name, party) in parties {
282            self = self.with_party(name, party)?;
283        }
284        Ok(self)
285    }
286
287    /// Starts building a transaction invocation.
288    ///
289    /// # Errors
290    ///
291    /// Returns [`Error::UnknownTx`] if `name` is not a transaction declared
292    /// by the protocol.
293    pub fn tx(&self, name: impl Into<String>) -> Result<TxBuilder, Error> {
294        let name = name.into();
295        let tir = self
296            .transactions
297            .get(&name)
298            .cloned()
299            .ok_or_else(|| Error::UnknownTx(name.clone()))?;
300
301        let params = self.tx_params.get(&name).cloned().unwrap_or_default();
302
303        Ok(TxBuilder::new(tir, self.trp.clone())
304            .params(params)
305            .env(self.env())
306            .parties(self.merged_parties()))
307    }
308
309    fn env(&self) -> EnvMap {
310        let mut env = self
311            .selected_profile
312            .as_ref()
313            .map(|profile| profile.environment.clone())
314            .unwrap_or_default();
315        for (key, value) in &self.env_overrides {
316            env.insert(key.clone(), value.clone());
317        }
318        env
319    }
320
321    fn merged_parties(&self) -> HashMap<String, Party> {
322        let mut merged = HashMap::new();
323        if let Some(profile) = &self.selected_profile {
324            for (name, address) in &profile.parties {
325                merged.insert(name.to_lowercase(), Party::address(address.clone()));
326            }
327        }
328        for (name, party) in &self.bound_parties {
329            merged.insert(name.clone(), party.clone());
330        }
331        merged
332    }
333}
334
335/// Builder for [`Tx3Client`].
336///
337/// Obtained via [`Protocol::client`]. All fallible validation — verifying
338/// that the selected profile exists, that every bound party is declared by
339/// the protocol — happens in [`Tx3ClientBuilder::build`]. Setters never
340/// return `Result`, so chains stay fluent.
341///
342/// # Example
343///
344/// ```ignore
345/// use tx3_sdk::tii::Protocol;
346/// use tx3_sdk::{Party};
347///
348/// let client = Protocol::from_file("protocol.tii")?
349///     .client()
350///     .trp_endpoint("https://trp.example")
351///     .with_profile("preprod")
352///     .with_party("sender", Party::address("addr_test1..."))
353///     .build()?;
354/// ```
355pub struct Tx3ClientBuilder {
356    transactions: HashMap<String, TirEnvelope>,
357    tx_params: HashMap<String, ParamMap>,
358    profiles: HashMap<String, Profile>,
359    known_parties: HashSet<String>,
360    trp_options: Option<trp::ClientOptions>,
361    profile: Option<String>,
362    parties: HashMap<String, Party>,
363    unchecked_parties: HashMap<String, Party>,
364    env_overrides: EnvMap,
365}
366
367impl Tx3ClientBuilder {
368    /// Seeds a builder with already-deconstructed protocol fragments. This is
369    /// the entry point used by codegen-generated bindings, which embed only
370    /// the runtime essentials at codegen time (per-tx TIR envelopes,
371    /// per-profile environment + party-address maps, declared party names)
372    /// and avoid carrying the rest of the TII document into the generated
373    /// crate.
374    pub fn from_parts(
375        transactions: HashMap<String, TirEnvelope>,
376        profiles: HashMap<String, Profile>,
377        known_parties: HashSet<String>,
378    ) -> Self {
379        let known_parties = known_parties
380            .into_iter()
381            .map(|name| name.to_lowercase())
382            .collect();
383        Self {
384            transactions,
385            tx_params: HashMap::new(),
386            profiles,
387            known_parties,
388            trp_options: None,
389            profile: None,
390            parties: HashMap::new(),
391            unchecked_parties: HashMap::new(),
392            env_overrides: EnvMap::new(),
393        }
394    }
395
396    pub(crate) fn from_protocol(protocol: Protocol) -> Self {
397        let transactions = protocol
398            .txs()
399            .iter()
400            .map(|(name, tx)| (name.clone(), tx.tir.clone()))
401            .collect();
402
403        let tx_params = protocol
404            .txs()
405            .keys()
406            .filter_map(|name| {
407                protocol
408                    .tx_params(name)
409                    .ok()
410                    .map(|params| (name.clone(), params))
411            })
412            .collect();
413
414        let profiles = protocol
415            .profiles()
416            .iter()
417            .map(|(name, profile)| {
418                let environment = profile.environment.as_object().cloned().unwrap_or_default();
419                (
420                    name.clone(),
421                    Profile {
422                        environment,
423                        parties: profile.parties.clone(),
424                    },
425                )
426            })
427            .collect();
428
429        let known_parties = protocol.parties().keys().cloned().collect();
430
431        let mut builder = Self::from_parts(transactions, profiles, known_parties);
432        builder.tx_params = tx_params;
433        builder
434    }
435
436    /// Sets the full TRP client options.
437    pub fn trp(mut self, opts: trp::ClientOptions) -> Self {
438        self.trp_options = Some(opts);
439        self
440    }
441
442    /// Sets the TRP endpoint URL (no headers). Overwrites any previously
443    /// supplied options.
444    pub fn trp_endpoint(mut self, url: impl Into<String>) -> Self {
445        self.trp_options = Some(trp::ClientOptions {
446            endpoint: url.into(),
447            headers: None,
448        });
449        self
450    }
451
452    /// Adds a header to the TRP client. Initializes the TRP options to an
453    /// empty endpoint if not yet set — callers must still supply an endpoint
454    /// via [`Tx3ClientBuilder::trp`] or [`Tx3ClientBuilder::trp_endpoint`].
455    pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
456        let opts = self.trp_options.get_or_insert_with(|| trp::ClientOptions {
457            endpoint: String::new(),
458            headers: None,
459        });
460        opts.headers
461            .get_or_insert_with(HashMap::new)
462            .insert(key.into(), value.into());
463        self
464    }
465
466    /// Attaches the parameter-type map for a transaction, enabling
467    /// type-directed argument encoding into the TRP `TaggedArg` wire form at
468    /// resolve time (see [`crate::tii::encode`]).
469    ///
470    /// [`Protocol::client`] populates this automatically for every declared
471    /// transaction. Codegen-generated bindings call it with a map built from
472    /// their embedded params schema via [`crate::tii::params_from_schema`]. A
473    /// transaction without a map sends its arguments unencoded, leaving
474    /// coercion to the resolver.
475    pub fn with_tx_params(mut self, tx: impl Into<String>, params: ParamMap) -> Self {
476        self.tx_params.insert(tx.into(), params);
477        self
478    }
479
480    /// Selects a profile by name. Validated in `build()`.
481    pub fn with_profile(mut self, name: impl Into<String>) -> Self {
482        self.profile = Some(name.into());
483        self
484    }
485
486    /// Binds a party (signer or read-only address) by name. Validated in
487    /// `build()` against the protocol's declared parties.
488    pub fn with_party(mut self, name: impl Into<String>, party: Party) -> Self {
489        self.parties.insert(name.into().to_lowercase(), party);
490        self
491    }
492
493    /// Binds a party without validating the name against the protocol's
494    /// declared parties. The entry is carried straight through to the built
495    /// client.
496    ///
497    /// Intended for codegen-generated wrappers, which materialize one typed
498    /// setter per declared party — the name is baked in at codegen time, so
499    /// runtime validation would always pass and the embedded party-name set
500    /// can be omitted. Hand-written code SHOULD use [`Tx3ClientBuilder::with_party`].
501    pub fn with_party_unchecked(mut self, name: impl Into<String>, party: Party) -> Self {
502        self.unchecked_parties
503            .insert(name.into().to_lowercase(), party);
504        self
505    }
506
507    /// Binds multiple parties at once.
508    pub fn with_parties<I, K>(mut self, parties: I) -> Self
509    where
510        I: IntoIterator<Item = (K, Party)>,
511        K: Into<String>,
512    {
513        for (name, party) in parties {
514            self = self.with_party(name, party);
515        }
516        self
517    }
518
519    /// Sets a single environment value. Merged on top of the selected
520    /// profile's environment at resolve time (override wins).
521    pub fn with_env_value(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
522        self.env_overrides.insert(key.into(), value.into());
523        self
524    }
525
526    /// Validates the builder state and materializes the [`Tx3Client`].
527    ///
528    /// # Errors
529    ///
530    /// - [`Error::Trp`] if no TRP endpoint was supplied.
531    /// - [`Error::UnknownProfile`] if the selected profile is not declared
532    ///   by the protocol.
533    /// - [`Error::UnknownParty`] if any bound party is not declared by the
534    ///   protocol.
535    pub fn build(self) -> Result<Tx3Client, Error> {
536        let trp_options = self.trp_options.ok_or(Error::MissingTrpEndpoint)?;
537        if trp_options.endpoint.is_empty() {
538            return Err(Error::MissingTrpEndpoint);
539        }
540
541        let selected_profile = match self.profile {
542            Some(name) => Some(
543                self.profiles
544                    .get(&name)
545                    .cloned()
546                    .ok_or(Error::UnknownProfile(name))?,
547            ),
548            None => None,
549        };
550
551        for name in self.parties.keys() {
552            if !self.known_parties.contains(name) {
553                return Err(Error::UnknownParty(name.clone()));
554            }
555        }
556
557        let trp = trp::Client::new(trp_options);
558
559        let mut bound_parties = self.parties;
560        bound_parties.extend(self.unchecked_parties);
561
562        Ok(Tx3Client::from_parts(
563            self.transactions,
564            self.tx_params,
565            self.known_parties,
566            trp,
567            bound_parties,
568            selected_profile,
569            self.env_overrides,
570        ))
571    }
572}
573
574/// Assembles the TRP resolve request shared by every [`TxBuilder`].
575///
576/// `env` (profile values, with any profile-declared party addresses already
577/// folded in), bound party addresses, and caller-supplied `args` are merged
578/// into a single argument map, in increasing order of precedence. The request
579/// `env` is left unset — TRP receives one argument map.
580///
581/// Every merged value whose name matches a `params` entry is marshalled by its
582/// `.tii` [`crate::tii::ParamType`] into the TRP `TaggedArg` wire form
583/// (top-level scalars bare, aggregates tagged — see [`crate::tii::encode`]).
584/// An unmapped value has no type, so it passes through untouched. Merged keys
585/// are lowercased on insert while params keep their original case, so the
586/// match is case-insensitive.
587fn build_resolve_params(
588    tir: TirEnvelope,
589    env: EnvMap,
590    parties: &HashMap<String, Party>,
591    args: ArgMap,
592    params: &ParamMap,
593) -> Result<ResolveParams, Error> {
594    let mut merged = ArgMap::new();
595    merged.extend(env);
596    for (name, party) in parties {
597        merged.insert(
598            name.clone(),
599            Value::String(party.address_value().to_string()),
600        );
601    }
602    merged.extend(args);
603
604    let encoded = merged
605        .into_iter()
606        .map(|(key, value)| {
607            match params
608                .iter()
609                .find(|(name, _)| name.to_lowercase() == key.to_lowercase())
610            {
611                Some((_, ty)) => {
612                    let value = tii::encode(ty, &value).map_err(tii::Error::from)?;
613                    Ok((key, value))
614                }
615                None => Ok((key, value)),
616            }
617        })
618        .collect::<Result<_, tii::Error>>()?;
619
620    Ok(ResolveParams {
621        tir,
622        args: encoded,
623        env: None,
624    })
625}
626
627/// Builder for transaction invocation.
628///
629/// A builder is a TIR envelope plus the environment, arguments, and party
630/// bindings needed to resolve it. Generated codegen clients construct one via
631/// [`TxBuilder::new`]; the dynamic [`Tx3Client`] constructs one by adapting a
632/// loaded [`Protocol`]. Both drive an identical resolve path.
633pub struct TxBuilder {
634    tir: TirEnvelope,
635    env: EnvMap,
636    trp: trp::Client,
637    args: ArgMap,
638    parties: HashMap<String, Party>,
639    params: ParamMap,
640}
641
642impl TxBuilder {
643    /// Creates a builder from a TIR envelope.
644    ///
645    /// This is the entry point used by generated codegen clients: they bake the
646    /// per-transaction TIR and profile data into the generated source at
647    /// codegen time and drive the full `resolve → sign → submit → wait`
648    /// lifecycle without loading a `.tii` file. Supply environment values with
649    /// [`TxBuilder::env`] and signer/address bindings with [`TxBuilder::parties`].
650    pub fn new(tir: TirEnvelope, trp: trp::Client) -> Self {
651        TxBuilder {
652            tir,
653            env: EnvMap::new(),
654            trp,
655            args: ArgMap::new(),
656            parties: HashMap::new(),
657            params: ParamMap::new(),
658        }
659    }
660
661    /// Sets the environment values applied to this transaction.
662    pub fn env(mut self, env: EnvMap) -> Self {
663        self.env = env;
664        self
665    }
666
667    /// Sets the parameter-type map used to marshal argument values into the
668    /// TRP `TaggedArg` wire form at resolve time. Arguments without a matching
669    /// entry pass through unencoded. See [`Tx3ClientBuilder::with_tx_params`].
670    pub fn params(mut self, params: ParamMap) -> Self {
671        self.params = params;
672        self
673    }
674
675    /// Attaches party definitions (signers or read-only addresses).
676    ///
677    /// Names are matched case-insensitively. Later entries override earlier
678    /// ones with the same name.
679    pub fn parties(mut self, parties: HashMap<String, Party>) -> Self {
680        for (name, party) in parties {
681            self.parties.insert(name.to_lowercase(), party);
682        }
683        self
684    }
685
686    /// Adds a single argument (case-insensitive name).
687    pub fn arg(mut self, name: &str, value: impl Into<Value>) -> Self {
688        self.args.insert(name.to_lowercase(), value.into());
689        self
690    }
691
692    /// Adds multiple arguments (case-insensitive names).
693    pub fn args(mut self, args: ArgMap) -> Self {
694        for (key, value) in args {
695            self.args.insert(key.to_lowercase(), value);
696        }
697        self
698    }
699
700    /// Resolves the transaction using the TRP client.
701    pub async fn resolve(self) -> Result<ResolvedTx, Error> {
702        let TxBuilder {
703            tir,
704            env,
705            trp,
706            args,
707            parties,
708            params,
709        } = self;
710
711        let resolve_params = build_resolve_params(tir, env, &parties, args, &params)?;
712
713        let envelope = trp.resolve(resolve_params).await?;
714
715        let signers = parties
716            .iter()
717            .filter_map(|(name, party)| party.signer_party(name))
718            .collect();
719
720        Ok(ResolvedTx {
721            trp,
722            hash: envelope.hash,
723            tx_hex: envelope.tx,
724            signers,
725            manual_witnesses: Vec::new(),
726        })
727    }
728}
729
730/// A resolved transaction ready for signing.
731pub struct ResolvedTx {
732    trp: trp::Client,
733    /// Transaction hash.
734    pub hash: String,
735    /// Hex-encoded CBOR transaction bytes.
736    pub tx_hex: String,
737    signers: Vec<SignerParty>,
738    manual_witnesses: Vec<TxWitness>,
739}
740
741impl ResolvedTx {
742    /// Returns the transaction hash that signers will sign.
743    pub fn signing_hash(&self) -> &str {
744        &self.hash
745    }
746
747    /// Attaches a pre-computed witness produced outside any registered `Signer`.
748    ///
749    /// This is the canonical entry point for wallet-app integrations: the consumer
750    /// hands `txHex` (or `hash`) to an external wallet, gets back a witness, and
751    /// attaches it before calling `sign()`. The witness is appended to the TRP
752    /// `SubmitParams.witnesses` array after any witnesses produced by registered
753    /// signer parties, in attach order. May be called any number of times.
754    ///
755    /// The SDK does not verify the witness against the tx hash; that binding is
756    /// enforced by TRP at submit time.
757    pub fn add_witness(mut self, witness: TxWitness) -> Self {
758        self.manual_witnesses.push(witness);
759        self
760    }
761
762    /// Signs the transaction with every signer party.
763    ///
764    /// Manually attached witnesses (via `add_witness`) are appended after
765    /// witnesses produced by registered signer parties, in attach order.
766    /// Succeeds with zero registered signers when at least one witness has
767    /// been manually attached.
768    pub fn sign(self) -> Result<SignedTx, Error> {
769        let total = self.signers.len() + self.manual_witnesses.len();
770        let mut witnesses = Vec::with_capacity(total);
771        let mut witnesses_info = Vec::with_capacity(total);
772
773        let request = SignRequest {
774            tx_hash_hex: self.hash.clone(),
775            tx_cbor_hex: self.tx_hex.clone(),
776        };
777
778        for signer_party in &self.signers {
779            let witness = signer_party.signer.sign(&request).map_err(Error::Signer)?;
780            witnesses_info.push(WitnessInfo {
781                party: signer_party.name.clone(),
782                address: signer_party.address.clone(),
783                key: witness.key.clone(),
784                signature: witness.signature.clone(),
785                witness_type: witness.witness_type.clone(),
786                signed_hash: self.hash.clone(),
787            });
788            witnesses.push(witness);
789        }
790
791        for witness in self.manual_witnesses {
792            witnesses_info.push(WitnessInfo {
793                party: "<external>".to_string(),
794                address: String::new(),
795                key: witness.key.clone(),
796                signature: witness.signature.clone(),
797                witness_type: witness.witness_type.clone(),
798                signed_hash: self.hash.clone(),
799            });
800            witnesses.push(witness);
801        }
802
803        let submit = SubmitParams {
804            tx: BytesEnvelope {
805                content: self.tx_hex,
806                content_type: "hex".to_string(),
807            },
808            witnesses,
809        };
810
811        Ok(SignedTx {
812            trp: self.trp,
813            hash: self.hash,
814            submit,
815            witnesses_info,
816        })
817    }
818}
819
820/// Witness payloads for submission.
821#[derive(Debug, Clone)]
822pub struct WitnessInfo {
823    /// Party name from the protocol.
824    pub party: String,
825    /// Party address used in invocation args.
826    pub address: String,
827    /// Public key envelope sent to the server.
828    pub key: BytesEnvelope,
829    /// Signature envelope sent to the server.
830    pub signature: BytesEnvelope,
831    /// Witness type.
832    pub witness_type: trp::WitnessType,
833    /// Transaction hash that was signed.
834    pub signed_hash: String,
835}
836
837/// A signed transaction ready for submission.
838pub struct SignedTx {
839    trp: trp::Client,
840    /// Resolved transaction hash.
841    pub hash: String,
842    /// Submit parameters including witnesses.
843    pub submit: SubmitParams,
844    witnesses_info: Vec<WitnessInfo>,
845}
846
847impl SignedTx {
848    /// Returns witness payloads for submission.
849    pub fn witnesses(&self) -> &[WitnessInfo] {
850        &self.witnesses_info
851    }
852    /// Submits the signed transaction.
853    pub async fn submit(self) -> Result<SubmittedTx, Error> {
854        let response = self.trp.submit(self.submit).await?;
855
856        if response.hash != self.hash {
857            return Err(Error::SubmitHashMismatch {
858                expected: self.hash,
859                received: response.hash,
860            });
861        }
862
863        Ok(SubmittedTx {
864            trp: self.trp,
865            hash: response.hash,
866        })
867    }
868}
869
870/// A submitted transaction that can be polled for status.
871pub struct SubmittedTx {
872    trp: trp::Client,
873    /// Submitted transaction hash.
874    pub hash: String,
875}
876
877impl SubmittedTx {
878    /// Polls check-status until the transaction is confirmed or fails.
879    pub async fn wait_for_confirmed(&self, config: PollConfig) -> Result<TxStatus, Error> {
880        self.wait_for_stage(config, TxStage::Confirmed).await
881    }
882
883    /// Polls check-status until the transaction is finalized or fails.
884    pub async fn wait_for_finalized(&self, config: PollConfig) -> Result<TxStatus, Error> {
885        self.wait_for_stage(config, TxStage::Finalized).await
886    }
887
888    async fn wait_for_stage(&self, config: PollConfig, target: TxStage) -> Result<TxStatus, Error> {
889        for attempt in 1..=config.attempts {
890            let response = self.trp.check_status(vec![self.hash.clone()]).await?;
891
892            if let Some(status) = response.statuses.get(&self.hash) {
893                match status.stage {
894                    TxStage::Finalized => return Ok(status.clone()),
895                    TxStage::Confirmed if matches!(target, TxStage::Confirmed) => {
896                        return Ok(status.clone())
897                    }
898                    TxStage::Dropped | TxStage::RolledBack => {
899                        return Err(Error::FinalizedFailed {
900                            hash: self.hash.clone(),
901                            stage: status.stage.clone(),
902                        });
903                    }
904                    _ => {}
905                }
906            }
907
908            if attempt < config.attempts {
909                tokio::time::sleep(config.delay).await;
910            }
911        }
912
913        Err(Error::FinalizedTimeout {
914            hash: self.hash.clone(),
915            attempts: config.attempts,
916            delay: config.delay,
917        })
918    }
919}
920
921/// Signer implementations.
922pub mod signer {
923    use super::{SignRequest, Signer};
924    use crate::core::BytesEnvelope;
925    use crate::trp::{TxWitness, WitnessType};
926    use cryptoxide::hmac::Hmac;
927    use cryptoxide::pbkdf2::pbkdf2;
928    use cryptoxide::sha2::Sha512;
929    use ed25519_bip32::{DerivationScheme, XPrv, XPRV_SIZE};
930    use pallas_addresses::{Address, ShelleyPaymentPart};
931    use pallas_crypto::hash::Hasher;
932    use pallas_crypto::key::ed25519::{SecretKey, SecretKeyExtended, Signature};
933    use thiserror::Error;
934
935    /// Errors returned by the built-in ed25519 signer.
936    #[derive(Debug, Error)]
937    pub enum SignerError {
938        /// Mnemonic phrase could not be parsed.
939        #[error("invalid mnemonic: {0}")]
940        InvalidMnemonic(bip39::Error),
941
942        /// Private key hex could not be decoded.
943        #[error("invalid private key hex: {0}")]
944        InvalidPrivateKeyHex(hex::FromHexError),
945
946        /// Private key length is not 32 bytes.
947        #[error("private key must be 32 bytes, got {0}")]
948        InvalidPrivateKeyLength(usize),
949
950        /// Transaction hash hex could not be decoded.
951        #[error("invalid tx hash hex: {0}")]
952        InvalidHashHex(hex::FromHexError),
953
954        /// Transaction hash length is not 32 bytes.
955        #[error("transaction hash must be 32 bytes, got {0}")]
956        InvalidHashLength(usize),
957
958        /// Address could not be parsed.
959        #[error("invalid address: {0}")]
960        InvalidAddress(pallas_addresses::Error),
961
962        /// Address does not contain a payment key hash.
963        #[error("address does not contain a payment key hash")]
964        UnsupportedPaymentCredential,
965
966        /// Signer key doesn't match address payment key.
967        #[error("signer key doesn't match address payment key")]
968        AddressMismatch,
969    }
970
971    /// Built-in ed25519 signer using a 32-byte private key.
972    ///
973    /// The address is required at construction and returned via `Signer::address`.
974    ///
975    /// # Example
976    ///
977    /// ```rust
978    /// use tx3_sdk::Ed25519Signer;
979    ///
980    /// let signer = Ed25519Signer::from_hex("addr_test1...", "deadbeef...")?;
981    /// # Ok::<(), tx3_sdk::Error>(())
982    /// ```
983    #[derive(Debug, Clone)]
984    pub struct Ed25519Signer {
985        address: String,
986        private_key: [u8; 32],
987    }
988
989    impl Ed25519Signer {
990        /// Creates a signer from a raw 32-byte private key and address.
991        pub fn new(address: impl Into<String>, private_key: [u8; 32]) -> Self {
992            Self {
993                address: address.into(),
994                private_key,
995            }
996        }
997
998        /// Creates a signer from a BIP39 mnemonic phrase.
999        ///
1000        /// The address is required and stored on the signer.
1001        pub fn from_mnemonic(
1002            address: impl Into<String>,
1003            phrase: &str,
1004        ) -> Result<Self, SignerError> {
1005            let mnemonic = bip39::Mnemonic::parse(phrase).map_err(SignerError::InvalidMnemonic)?;
1006            let seed = mnemonic.to_seed("");
1007
1008            let mut key_array = [0u8; 32];
1009            key_array.copy_from_slice(&seed[0..32]);
1010
1011            Ok(Self::new(address, key_array))
1012        }
1013
1014        /// Creates a signer from a hex-encoded 32-byte private key.
1015        ///
1016        /// The address is required and stored on the signer.
1017        pub fn from_hex(
1018            address: impl Into<String>,
1019            private_key_hex: &str,
1020        ) -> Result<Self, SignerError> {
1021            let key_bytes =
1022                hex::decode(private_key_hex).map_err(SignerError::InvalidPrivateKeyHex)?;
1023
1024            if key_bytes.len() != 32 {
1025                return Err(SignerError::InvalidPrivateKeyLength(key_bytes.len()));
1026            }
1027
1028            let mut key_array = [0u8; 32];
1029            key_array.copy_from_slice(&key_bytes);
1030
1031            Ok(Self::new(address, key_array))
1032        }
1033    }
1034
1035    /// Cardano signer that derives witness key from address payment part.
1036    ///
1037    /// This signer derives keys using the Cardano path `m/1852'/1815'/0'/0/0`.
1038    ///
1039    /// # Example
1040    ///
1041    /// ```rust
1042    /// use tx3_sdk::CardanoSigner;
1043    ///
1044    /// let signer = CardanoSigner::from_mnemonic(
1045    ///     "addr_test1...",
1046    ///     "word1 word2 ... word24",
1047    /// )?;
1048    /// # Ok::<(), tx3_sdk::Error>(())
1049    /// ```
1050    #[derive(Debug, Clone)]
1051    pub struct CardanoSigner {
1052        address: String,
1053        private_key: CardanoPrivateKey,
1054        payment_key_hash: Vec<u8>,
1055    }
1056
1057    #[derive(Debug, Clone)]
1058    enum CardanoPrivateKey {
1059        Normal(SecretKey),
1060        Extended(SecretKeyExtended),
1061    }
1062
1063    impl CardanoPrivateKey {
1064        fn public_key_bytes(&self) -> Vec<u8> {
1065            match self {
1066                CardanoPrivateKey::Normal(key) => key.public_key().as_ref().to_vec(),
1067                CardanoPrivateKey::Extended(key) => key.public_key().as_ref().to_vec(),
1068            }
1069        }
1070
1071        fn sign(&self, msg: &[u8]) -> Signature {
1072            match self {
1073                CardanoPrivateKey::Normal(key) => key.sign(msg),
1074                CardanoPrivateKey::Extended(key) => key.sign(msg),
1075            }
1076        }
1077    }
1078
1079    impl CardanoSigner {
1080        /// Creates a Cardano signer from a raw private key and address.
1081        fn new(
1082            private_key: CardanoPrivateKey,
1083            address: impl Into<String>,
1084        ) -> Result<Self, SignerError> {
1085            let address = address.into();
1086            let payment_key_hash = extract_payment_key_hash(&address)?;
1087            Ok(Self {
1088                address,
1089                private_key,
1090                payment_key_hash,
1091            })
1092        }
1093
1094        /// Creates a Cardano signer from a hex-encoded private key and address.
1095        pub fn from_hex(
1096            address: impl Into<String>,
1097            private_key_hex: &str,
1098        ) -> Result<Self, SignerError> {
1099            let key_bytes =
1100                hex::decode(private_key_hex).map_err(SignerError::InvalidPrivateKeyHex)?;
1101
1102            if key_bytes.len() != 32 {
1103                return Err(SignerError::InvalidPrivateKeyLength(key_bytes.len()));
1104            }
1105
1106            let mut key_array = [0u8; 32];
1107            key_array.copy_from_slice(&key_bytes);
1108
1109            let key: SecretKey = key_array.into();
1110
1111            Self::new(CardanoPrivateKey::Normal(key), address)
1112        }
1113
1114        /// Creates a Cardano signer from a mnemonic phrase and address.
1115        pub fn from_mnemonic(
1116            address: impl Into<String>,
1117            phrase: &str,
1118        ) -> Result<Self, SignerError> {
1119            let root = derive_root_xprv(phrase, "")?;
1120            let payment = derive_cardano_payment_xprv(&root);
1121            let key =
1122                unsafe { SecretKeyExtended::from_bytes_unchecked(payment.extended_secret_key()) };
1123
1124            Self::new(CardanoPrivateKey::Extended(key), address)
1125        }
1126
1127        fn verify_address_binding(&self, public_key_bytes: &[u8]) -> Result<(), SignerError> {
1128            let mut hasher = Hasher::<224>::new();
1129            hasher.input(public_key_bytes);
1130            let digest = hasher.finalize();
1131
1132            if digest.as_ref() != self.payment_key_hash.as_slice() {
1133                return Err(SignerError::AddressMismatch);
1134            }
1135
1136            Ok(())
1137        }
1138    }
1139
1140    impl Signer for CardanoSigner {
1141        fn address(&self) -> &str {
1142            &self.address
1143        }
1144
1145        fn sign(
1146            &self,
1147            request: &SignRequest,
1148        ) -> Result<TxWitness, Box<dyn std::error::Error + Send + Sync>> {
1149            let hash_bytes = hex::decode(&request.tx_hash_hex).map_err(|err| {
1150                Box::new(SignerError::InvalidHashHex(err))
1151                    as Box<dyn std::error::Error + Send + Sync>
1152            })?;
1153
1154            if hash_bytes.len() != 32 {
1155                return Err(Box::new(SignerError::InvalidHashLength(hash_bytes.len())));
1156            }
1157
1158            let public_key_bytes = self.private_key.public_key_bytes();
1159
1160            let _ = self.verify_address_binding(&public_key_bytes);
1161
1162            let signature = self.private_key.sign(&hash_bytes);
1163
1164            Ok(TxWitness {
1165                key: BytesEnvelope {
1166                    content: hex::encode(&public_key_bytes),
1167                    content_type: "hex".to_string(),
1168                },
1169                signature: BytesEnvelope {
1170                    content: hex::encode(signature.as_ref()),
1171                    content_type: "hex".to_string(),
1172                },
1173                witness_type: WitnessType::VKey,
1174            })
1175        }
1176    }
1177
1178    fn derive_root_xprv(phrase: &str, password: &str) -> Result<XPrv, SignerError> {
1179        let mnemonic = bip39::Mnemonic::parse(phrase).map_err(SignerError::InvalidMnemonic)?;
1180        let entropy = mnemonic.to_entropy();
1181
1182        let mut pbkdf2_result = [0u8; XPRV_SIZE];
1183
1184        const ITER: u32 = 4096;
1185
1186        let mut mac = Hmac::new(Sha512::new(), password.as_bytes());
1187        pbkdf2(&mut mac, &entropy, ITER, &mut pbkdf2_result);
1188
1189        Ok(XPrv::normalize_bytes_force3rd(pbkdf2_result))
1190    }
1191
1192    fn derive_cardano_payment_xprv(root: &XPrv) -> XPrv {
1193        const HARDENED: u32 = 0x8000_0000;
1194
1195        root.derive(DerivationScheme::V2, 1852 | HARDENED)
1196            .derive(DerivationScheme::V2, 1815 | HARDENED)
1197            .derive(DerivationScheme::V2, HARDENED)
1198            .derive(DerivationScheme::V2, 0)
1199            .derive(DerivationScheme::V2, 0)
1200    }
1201
1202    fn extract_payment_key_hash(address: &str) -> Result<Vec<u8>, SignerError> {
1203        let parsed = Address::from_bech32(address).map_err(SignerError::InvalidAddress)?;
1204
1205        let payment = match parsed {
1206            Address::Shelley(addr) => addr.payment().clone(),
1207            _ => return Err(SignerError::UnsupportedPaymentCredential),
1208        };
1209
1210        match payment {
1211            ShelleyPaymentPart::Key(hash) => Ok(hash.as_ref().to_vec()),
1212            ShelleyPaymentPart::Script(_) => Err(SignerError::UnsupportedPaymentCredential),
1213        }
1214    }
1215
1216    impl Signer for Ed25519Signer {
1217        fn address(&self) -> &str {
1218            &self.address
1219        }
1220
1221        fn sign(
1222            &self,
1223            request: &SignRequest,
1224        ) -> Result<TxWitness, Box<dyn std::error::Error + Send + Sync>> {
1225            let hash_bytes = hex::decode(&request.tx_hash_hex).map_err(|err| {
1226                Box::new(SignerError::InvalidHashHex(err))
1227                    as Box<dyn std::error::Error + Send + Sync>
1228            })?;
1229
1230            if hash_bytes.len() != 32 {
1231                return Err(Box::new(SignerError::InvalidHashLength(hash_bytes.len())));
1232            }
1233
1234            let signing_key: SecretKey = self.private_key.into();
1235            let public_key = signing_key.public_key();
1236            let signature = signing_key.sign(&hash_bytes);
1237
1238            Ok(TxWitness {
1239                key: BytesEnvelope {
1240                    content: hex::encode(public_key.as_ref()),
1241                    content_type: "hex".to_string(),
1242                },
1243                signature: BytesEnvelope {
1244                    content: hex::encode(signature.as_ref()),
1245                    content_type: "hex".to_string(),
1246                },
1247                witness_type: WitnessType::VKey,
1248            })
1249        }
1250    }
1251}
1252
1253#[cfg(test)]
1254mod tests {
1255    use super::*;
1256    use crate::trp::{ClientOptions, WitnessType};
1257
1258    fn stub_trp() -> trp::Client {
1259        trp::Client::new(ClientOptions {
1260            endpoint: "http://localhost:0/unused".to_string(),
1261            headers: None,
1262        })
1263    }
1264
1265    fn fake_witness(key_hex: &str, sig_hex: &str) -> TxWitness {
1266        TxWitness {
1267            key: BytesEnvelope {
1268                content: key_hex.to_string(),
1269                content_type: "hex".to_string(),
1270            },
1271            signature: BytesEnvelope {
1272                content: sig_hex.to_string(),
1273                content_type: "hex".to_string(),
1274            },
1275            witness_type: WitnessType::VKey,
1276        }
1277    }
1278
1279    fn empty_resolved() -> ResolvedTx {
1280        ResolvedTx {
1281            trp: stub_trp(),
1282            hash: "deadbeef".to_string(),
1283            tx_hex: "84a40081".to_string(),
1284            signers: Vec::new(),
1285            manual_witnesses: Vec::new(),
1286        }
1287    }
1288
1289    struct StubSigner {
1290        address: String,
1291        witness: TxWitness,
1292    }
1293
1294    impl Signer for StubSigner {
1295        fn address(&self) -> &str {
1296            &self.address
1297        }
1298
1299        fn sign(
1300            &self,
1301            _request: &SignRequest,
1302        ) -> Result<TxWitness, Box<dyn std::error::Error + Send + Sync>> {
1303            Ok(self.witness.clone())
1304        }
1305    }
1306
1307    #[test]
1308    fn add_witness_only_no_signers() {
1309        let witness = fake_witness("aa", "bb");
1310        let signed = empty_resolved()
1311            .add_witness(witness.clone())
1312            .sign()
1313            .expect("sign with manual witness only must succeed");
1314
1315        assert_eq!(signed.submit.witnesses.len(), 1);
1316        assert_eq!(signed.submit.witnesses[0].key.content, witness.key.content);
1317        assert_eq!(
1318            signed.submit.witnesses[0].signature.content,
1319            witness.signature.content
1320        );
1321    }
1322
1323    #[test]
1324    fn add_witness_mixed_with_registered_signer() {
1325        let registered_witness = fake_witness("11", "22");
1326        let manual_witness = fake_witness("aa", "bb");
1327
1328        let stub = StubSigner {
1329            address: "addr_test1...".to_string(),
1330            witness: registered_witness.clone(),
1331        };
1332
1333        let resolved = ResolvedTx {
1334            trp: stub_trp(),
1335            hash: "deadbeef".to_string(),
1336            tx_hex: "84a40081".to_string(),
1337            signers: vec![SignerParty {
1338                name: "sender".to_string(),
1339                address: stub.address.clone(),
1340                signer: Arc::new(stub),
1341            }],
1342            manual_witnesses: Vec::new(),
1343        };
1344
1345        let signed = resolved
1346            .add_witness(manual_witness.clone())
1347            .sign()
1348            .expect("sign with mixed witnesses must succeed");
1349
1350        assert_eq!(signed.submit.witnesses.len(), 2);
1351        assert_eq!(signed.submit.witnesses[0].key.content, "11");
1352        assert_eq!(signed.submit.witnesses[1].key.content, "aa");
1353    }
1354
1355    #[test]
1356    fn add_witness_preserves_attach_order() {
1357        let signed = empty_resolved()
1358            .add_witness(fake_witness("01", "10"))
1359            .add_witness(fake_witness("02", "20"))
1360            .add_witness(fake_witness("03", "30"))
1361            .sign()
1362            .expect("sign must succeed");
1363
1364        let keys: Vec<&str> = signed
1365            .submit
1366            .witnesses
1367            .iter()
1368            .map(|w| w.key.content.as_str())
1369            .collect();
1370        assert_eq!(keys, vec!["01", "02", "03"]);
1371    }
1372
1373    fn sample_tir() -> TirEnvelope {
1374        TirEnvelope {
1375            content: "abcd".to_string(),
1376            encoding: crate::core::TirEncoding::Hex,
1377            version: "v1beta0".to_string(),
1378        }
1379    }
1380
1381    #[test]
1382    fn resolve_params_merges_env_parties_and_args() {
1383        let mut env = EnvMap::new();
1384        env.insert("network".to_string(), serde_json::json!("testnet"));
1385
1386        let mut parties = HashMap::new();
1387        parties.insert("receiver".to_string(), Party::address("addr_receiver"));
1388
1389        let mut args = ArgMap::new();
1390        args.insert("quantity".to_string(), serde_json::json!(10_000_000));
1391
1392        let params =
1393            build_resolve_params(sample_tir(), env, &parties, args, &ParamMap::new()).unwrap();
1394
1395        assert_eq!(params.env, None);
1396        assert_eq!(params.tir.content, "abcd");
1397        assert_eq!(
1398            params.args.get("network").unwrap(),
1399            &serde_json::json!("testnet")
1400        );
1401        assert_eq!(
1402            params.args.get("receiver").unwrap(),
1403            &serde_json::json!("addr_receiver")
1404        );
1405        assert_eq!(
1406            params.args.get("quantity").unwrap(),
1407            &serde_json::json!(10_000_000)
1408        );
1409    }
1410
1411    #[test]
1412    fn resolve_params_args_override_env() {
1413        let mut env = EnvMap::new();
1414        env.insert("quantity".to_string(), serde_json::json!(1));
1415
1416        let mut args = ArgMap::new();
1417        args.insert("quantity".to_string(), serde_json::json!(999));
1418
1419        let params =
1420            build_resolve_params(sample_tir(), env, &HashMap::new(), args, &ParamMap::new())
1421                .unwrap();
1422
1423        assert_eq!(
1424            params.args.get("quantity").unwrap(),
1425            &serde_json::json!(999)
1426        );
1427    }
1428
1429    #[test]
1430    fn resolve_params_uses_signer_party_address() {
1431        let stub = StubSigner {
1432            address: "addr_signer".to_string(),
1433            witness: fake_witness("aa", "bb"),
1434        };
1435
1436        let mut parties = HashMap::new();
1437        parties.insert("sender".to_string(), Party::signer(stub));
1438
1439        let params = build_resolve_params(
1440            sample_tir(),
1441            EnvMap::new(),
1442            &parties,
1443            ArgMap::new(),
1444            &ParamMap::new(),
1445        )
1446        .unwrap();
1447
1448        assert_eq!(
1449            params.args.get("sender").unwrap(),
1450            &serde_json::json!("addr_signer")
1451        );
1452    }
1453
1454    /// Interprets a JSON schema node into a [`ParamType`] the way the SDK reads
1455    /// a `.tii` params schema.
1456    fn param_of(schema: serde_json::Value) -> crate::tii::ParamType {
1457        crate::tii::ParamType::from_json_schema(&schema, &HashMap::new())
1458    }
1459
1460    #[test]
1461    fn resolve_params_encode_typed_args_to_tagged_wire_form() {
1462        // The facade resolve path (used by both the dynamic client and
1463        // codegen-generated bindings) must marshal typed args into the
1464        // `TaggedArg` wire form — regression for Hydra `init`
1465        // `participants: vec![vec![1, 2]]` reaching the resolver raw and
1466        // failing with `(-32005) value is not bytes: [1,2]`.
1467        let mut params = ParamMap::new();
1468        params.insert(
1469            "participants".to_string(),
1470            param_of(serde_json::json!({
1471                "type": "array",
1472                "items": { "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes" }
1473            })),
1474        );
1475        params.insert(
1476            "head_id".to_string(),
1477            param_of(
1478                serde_json::json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes" }),
1479            ),
1480        );
1481
1482        let mut args = ArgMap::new();
1483        args.insert("participants".to_string(), serde_json::json!([[1, 2]]));
1484        args.insert("head_id".to_string(), serde_json::json!("abcd0123"));
1485
1486        let resolve =
1487            build_resolve_params(sample_tir(), EnvMap::new(), &HashMap::new(), args, &params)
1488                .unwrap();
1489
1490        assert_eq!(
1491            resolve.args.get("participants").unwrap(),
1492            &serde_json::json!({ "list": [{ "bytes": "0x0102" }] })
1493        );
1494        // A top-level scalar stays bare; the resolver coerces it.
1495        assert_eq!(
1496            resolve.args.get("head_id").unwrap(),
1497            &serde_json::json!("abcd0123")
1498        );
1499    }
1500
1501    #[test]
1502    fn resolve_params_match_param_names_case_insensitively() {
1503        // Arg keys are lowercased on set while `.tii` params keep their
1504        // original case; the encoder lookup must still find them.
1505        let mut params = ParamMap::new();
1506        params.insert(
1507            "Participants".to_string(),
1508            param_of(serde_json::json!({
1509                "type": "array",
1510                "items": { "type": "integer" }
1511            })),
1512        );
1513
1514        let mut args = ArgMap::new();
1515        args.insert("participants".to_string(), serde_json::json!([7]));
1516
1517        let resolve =
1518            build_resolve_params(sample_tir(), EnvMap::new(), &HashMap::new(), args, &params)
1519                .unwrap();
1520
1521        assert_eq!(
1522            resolve.args.get("participants").unwrap(),
1523            &serde_json::json!({ "list": [{ "int": 7 }] })
1524        );
1525    }
1526
1527    #[test]
1528    fn resolve_params_pass_unmapped_args_through() {
1529        let mut args = ArgMap::new();
1530        args.insert("mystery".to_string(), serde_json::json!([[1, 2]]));
1531
1532        let resolve = build_resolve_params(
1533            sample_tir(),
1534            EnvMap::new(),
1535            &HashMap::new(),
1536            args,
1537            &ParamMap::new(),
1538        )
1539        .unwrap();
1540
1541        // No declared type: the value passes through for the resolver to judge.
1542        assert_eq!(
1543            resolve.args.get("mystery").unwrap(),
1544            &serde_json::json!([[1, 2]])
1545        );
1546    }
1547
1548    #[test]
1549    fn resolve_params_surface_encode_errors_preflight() {
1550        let mut params = ParamMap::new();
1551        params.insert(
1552            "name".to_string(),
1553            param_of(
1554                serde_json::json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes" }),
1555            ),
1556        );
1557
1558        let mut args = ArgMap::new();
1559        args.insert("name".to_string(), serde_json::json!(42));
1560
1561        let result =
1562            build_resolve_params(sample_tir(), EnvMap::new(), &HashMap::new(), args, &params);
1563
1564        assert!(matches!(
1565            result,
1566            Err(Error::Tii(crate::tii::Error::EncodeArg(_)))
1567        ));
1568    }
1569
1570    #[test]
1571    fn client_tx_wires_protocol_params_into_builder() {
1572        // `Protocol::client()` must thread each transaction's param types into
1573        // the facade so `resolve()` encodes typed args — the dynamic-path
1574        // equivalent of codegen's `with_tx_params`.
1575        let manifest_dir = env!("CARGO_MANIFEST_DIR");
1576        let tii = format!("{manifest_dir}/tests/fixtures/transfer.tii");
1577        let protocol = crate::tii::Protocol::from_file(&tii).unwrap();
1578
1579        let client = protocol
1580            .client()
1581            .trp_endpoint("http://localhost:0")
1582            .build()
1583            .unwrap();
1584
1585        let builder = client.tx("transfer").unwrap();
1586        assert!(
1587            !builder.params.is_empty(),
1588            "tx builder must receive the protocol's param types"
1589        );
1590    }
1591}