Skip to main content

soroban_cli/commands/contract/
arg_parsing.rs

1use crate::commands::contract::arg_parsing::Error::HelpMessage;
2use crate::commands::contract::deploy::wasm::CONSTRUCTOR_FUNCTION_NAME;
3use crate::commands::txn_result::TxnResult;
4use crate::config::{self, sc_address, UnresolvedScAddress};
5use crate::print::Print;
6use crate::signer::{self, Signer};
7use crate::xdr::{
8    self, Hash, InvokeContractArgs, ScSpecEntry, ScSpecFunctionV0, ScSpecTypeDef, ScVal, ScVec,
9};
10use clap::error::ErrorKind::DisplayHelp;
11use clap::value_parser;
12use heck::ToKebabCase;
13use soroban_spec_tools::{sanitize, Spec};
14use std::collections::HashMap;
15use std::convert::TryInto;
16use std::env;
17use std::ffi::OsString;
18use std::fmt::Debug;
19use std::path::PathBuf;
20use stellar_xdr::curr::ContractId;
21
22#[derive(thiserror::Error, Debug)]
23pub enum Error {
24    #[error("Failed to parse argument '{arg}': {error}\n\nContext: Expected type {expected_type}, but received: '{received_value}'\n\nSuggestion: {suggestion}")]
25    CannotParseArg {
26        arg: String,
27        error: soroban_spec_tools::Error,
28        expected_type: String,
29        received_value: String,
30        suggestion: String,
31    },
32    #[error("Invalid JSON in argument '{arg}': {json_error}\n\nReceived value: '{received_value}'\n\nSuggestions:\n- Check for missing quotes around strings\n- Ensure proper JSON syntax (commas, brackets, etc.)\n- For complex objects, consider using --{arg}-file-path to load from a file")]
33    InvalidJsonArg {
34        arg: String,
35        json_error: String,
36        received_value: String,
37    },
38    #[error("Type mismatch for argument '{arg}': expected {expected_type}, but got {actual_type}\n\nReceived value: '{received_value}'\n\nSuggestions:\n- For {expected_type}, ensure the value is properly formatted\n- Check the contract specification for the correct argument type")]
39    TypeMismatch {
40        arg: String,
41        expected_type: String,
42        actual_type: String,
43        received_value: String,
44    },
45    #[error("Missing required argument '{arg}' of type {expected_type}\n\nSuggestions:\n- Add the argument: --{arg} <value>\n- Or use a file: --{arg}-file-path <path-to-json-file>\n- Check the contract specification for required arguments")]
46    MissingArgument { arg: String, expected_type: String },
47    #[error("Cannot read file {file_path:?}: {error}\n\nSuggestions:\n- Check if the file exists and is readable\n- Ensure the file path is correct\n- Verify file permissions")]
48    MissingFileArg { file_path: PathBuf, error: String },
49    #[error("cannot print result {result:?}: {error}")]
50    CannotPrintResult {
51        result: ScVal,
52        error: soroban_spec_tools::Error,
53    },
54    #[error("function '{function_name}' was not found in the contract\n\nAvailable functions: {available_functions}\n\nSuggestions:\n- Check the function name spelling\n- Use 'stellar contract invoke --help' to see available functions\n- Verify the contract ID is correct")]
55    FunctionNotFoundInContractSpec {
56        function_name: String,
57        available_functions: String,
58    },
59    #[error("function name '{function_name}' is too long (max 32 characters)\n\nReceived: {function_name} ({length} characters)")]
60    FunctionNameTooLong {
61        function_name: String,
62        length: usize,
63    },
64    #[error("argument count ({current}) surpasses maximum allowed count ({maximum})\n\nSuggestions:\n- Reduce the number of arguments\n- Consider using file-based arguments for complex data\n- Check if some arguments can be combined")]
65    MaxNumberOfArgumentsReached { current: usize, maximum: usize },
66    #[error("Unsupported address type '{address}'\n\nSupported formats:\n- Account addresses: G... (starts with G)\n- Contract addresses: C... (starts with C)\n- Muxed accounts: M... (starts with M)\n- Identity names: alice, bob, etc.\n\nReceived: '{address}'")]
67    UnsupportedScAddress { address: String },
68    #[error("Duplicate map key '{key}' after alias resolution\n\nMultiple input keys resolved to the same address — likely an alias passed alongside its strkey, or two aliases pointing to the same identity.")]
69    DuplicateMapKey { key: String },
70    #[error(transparent)]
71    Xdr(#[from] xdr::Error),
72    #[error(transparent)]
73    StrVal(#[from] soroban_spec_tools::Error),
74    #[error(transparent)]
75    ScAddress(#[from] sc_address::Error),
76    #[error(transparent)]
77    Config(#[from] config::Error),
78    #[error("")]
79    HelpMessage(String),
80    #[error(transparent)]
81    Signer(#[from] signer::Error),
82}
83
84pub type HostFunctionParameters = (String, Spec, InvokeContractArgs, Vec<Signer>);
85
86fn running_cmd() -> String {
87    let mut args: Vec<String> = env::args().collect();
88
89    if let Some(pos) = args.iter().position(|arg| arg == "--") {
90        args.truncate(pos);
91    }
92
93    format!("{} --", args.join(" "))
94}
95
96pub fn build_host_function_parameters(
97    contract_id: &stellar_strkey::Contract,
98    slop: &[OsString],
99    spec_entries: &[ScSpecEntry],
100    config: &config::Args,
101) -> Result<HostFunctionParameters, Error> {
102    build_host_function_parameters_with_filter(contract_id, slop, spec_entries, config, true)
103}
104
105pub fn build_constructor_parameters(
106    contract_id: &stellar_strkey::Contract,
107    slop: &[OsString],
108    spec_entries: &[ScSpecEntry],
109    config: &config::Args,
110) -> Result<HostFunctionParameters, Error> {
111    build_host_function_parameters_with_filter(contract_id, slop, spec_entries, config, false)
112}
113
114fn build_host_function_parameters_with_filter(
115    contract_id: &stellar_strkey::Contract,
116    slop: &[OsString],
117    spec_entries: &[ScSpecEntry],
118    config: &config::Args,
119    filter_constructor: bool,
120) -> Result<HostFunctionParameters, Error> {
121    let spec = Spec(Some(spec_entries.to_vec()));
122    let cmd = build_clap_command(&spec, filter_constructor)?;
123    let (function, matches_) = parse_command_matches(cmd, slop)?;
124    let func = get_function_spec(&spec, &function)?;
125    let (parsed_args, signers) = parse_function_arguments(&func, &matches_, &spec, config)?;
126    let invoke_args = build_invoke_contract_args(contract_id, &function, parsed_args)?;
127
128    Ok((function, spec, invoke_args, signers))
129}
130
131fn build_clap_command(spec: &Spec, filter_constructor: bool) -> Result<clap::Command, Error> {
132    let mut cmd = clap::Command::new(running_cmd())
133        .no_binary_name(true)
134        .term_width(300)
135        .max_term_width(300);
136
137    for ScSpecFunctionV0 { name, .. } in spec.find_functions()? {
138        let function_name = name.to_utf8_string_lossy();
139        // Filter out the constructor function from the invoke command
140        if !filter_constructor || function_name != CONSTRUCTOR_FUNCTION_NAME {
141            cmd = cmd.subcommand(build_custom_cmd(&function_name, spec)?);
142        }
143    }
144    cmd.build();
145    Ok(cmd)
146}
147
148fn parse_command_matches(
149    mut cmd: clap::Command,
150    slop: &[OsString],
151) -> Result<(String, clap::ArgMatches), Error> {
152    let long_help = cmd.render_long_help();
153    let maybe_matches = cmd.try_get_matches_from(slop);
154
155    let Some((function, matches_)) = (match maybe_matches {
156        Ok(mut matches) => matches.remove_subcommand(),
157        Err(e) => {
158            if e.kind() == DisplayHelp {
159                return Err(HelpMessage(e.to_string()));
160            }
161            e.exit();
162        }
163    }) else {
164        return Err(HelpMessage(format!("{long_help}")));
165    };
166
167    Ok((function.clone(), matches_))
168}
169
170fn get_function_spec(spec: &Spec, function: &str) -> Result<ScSpecFunctionV0, Error> {
171    // Exact match (normal path).
172    if let Ok(f) = spec.find_function(function) {
173        return Ok(f.clone());
174    }
175    // Fallback: match against sanitized names for functions whose names contain
176    // control characters (clap registers the sanitized form as the command name).
177    if let Ok(functions) = spec.find_functions() {
178        for f in functions {
179            if sanitize(&f.name.to_utf8_string_lossy()) == function {
180                return Ok(f.clone());
181            }
182        }
183    }
184    Err(Error::FunctionNotFoundInContractSpec {
185        function_name: function.to_string(),
186        available_functions: get_available_functions(spec),
187    })
188}
189
190fn parse_function_arguments(
191    func: &ScSpecFunctionV0,
192    matches_: &clap::ArgMatches,
193    spec: &Spec,
194    config: &config::Args,
195) -> Result<(Vec<ScVal>, Vec<Signer>), Error> {
196    let mut parsed_args = Vec::with_capacity(func.inputs.len());
197    let mut signers = Vec::<Signer>::new();
198
199    for i in func.inputs.iter() {
200        parse_single_argument(i, matches_, spec, config, &mut signers, &mut parsed_args)?;
201    }
202
203    Ok((parsed_args, signers))
204}
205
206fn parse_single_argument(
207    input: &stellar_xdr::curr::ScSpecFunctionInputV0,
208    matches_: &clap::ArgMatches,
209    spec: &Spec,
210    config: &config::Args,
211    signers: &mut Vec<Signer>,
212    parsed_args: &mut Vec<ScVal>,
213) -> Result<(), Error> {
214    let name = sanitize(&input.name.to_utf8_string_lossy());
215    let expected_type_name = get_type_name(&input.type_); //-0--
216
217    if let Some(mut val) = matches_.get_raw(&name) {
218        let s = match val.next() {
219            Some(v) => v.to_string_lossy().to_string(),
220            None => {
221                return Err(Error::MissingArgument {
222                    arg: name.clone(),
223                    expected_type: expected_type_name,
224                });
225            }
226        };
227
228        // Collect a signer up front for top-level address args, so the
229        // alias-named identity can also sign the transaction. Alias-to-strkey
230        // resolution itself happens inside parse_argument_with_validation,
231        // which uniformly handles top-level and nested Address positions.
232        if matches!(
233            input.type_,
234            ScSpecTypeDef::Address | ScSpecTypeDef::MuxedAddress
235        ) {
236            let trimmed_s = s.trim_matches('"');
237            if let Some(signer) = resolve_signer(trimmed_s, config) {
238                signers.push(signer);
239            }
240        }
241
242        parsed_args.push(parse_argument_with_validation(
243            &name,
244            &s,
245            &input.type_,
246            spec,
247            config,
248        )?);
249        Ok(())
250    } else if matches!(input.type_, ScSpecTypeDef::Option(_)) {
251        parsed_args.push(ScVal::Void);
252        Ok(())
253    } else if let Some(arg_path) = matches_.get_one::<PathBuf>(&fmt_arg_file_name(&name)) {
254        parsed_args.push(parse_file_argument(
255            &name,
256            arg_path,
257            &input.type_,
258            expected_type_name,
259            spec,
260            config,
261        )?);
262        Ok(())
263    } else {
264        Err(Error::MissingArgument {
265            arg: name,
266            expected_type: expected_type_name,
267        })
268    }
269}
270
271fn parse_file_argument(
272    name: &str,
273    arg_path: &PathBuf,
274    type_def: &ScSpecTypeDef,
275    expected_type_name: String,
276    spec: &Spec,
277    config: &config::Args,
278) -> Result<ScVal, Error> {
279    if matches!(type_def, ScSpecTypeDef::Bytes | ScSpecTypeDef::BytesN(_)) {
280        let bytes = std::fs::read(arg_path).map_err(|e| Error::MissingFileArg {
281            file_path: arg_path.clone(),
282            error: e.to_string(),
283        })?;
284        ScVal::try_from(&bytes).map_err(|()| Error::CannotParseArg {
285            arg: name.to_string(),
286            error: soroban_spec_tools::Error::Unknown,
287            expected_type: expected_type_name,
288            received_value: format!("{} bytes from file", bytes.len()),
289            suggestion: "Ensure the file contains valid binary data for the expected byte type"
290                .to_string(),
291        })
292    } else {
293        let file_contents =
294            std::fs::read_to_string(arg_path).map_err(|e| Error::MissingFileArg {
295                file_path: arg_path.clone(),
296                error: e.to_string(),
297            })?;
298        tracing::debug!(
299            "file {arg_path:?}, has contents:\n{file_contents}\nAnd type {:#?}\n{}",
300            type_def,
301            file_contents.len()
302        );
303        parse_argument_with_validation(name, &file_contents, type_def, spec, config)
304    }
305}
306
307fn build_invoke_contract_args(
308    contract_id: &stellar_strkey::Contract,
309    function: &str,
310    parsed_args: Vec<ScVal>,
311) -> Result<InvokeContractArgs, Error> {
312    let contract_address_arg = xdr::ScAddress::Contract(ContractId(Hash(contract_id.0)));
313    let function_symbol_arg = function
314        .try_into()
315        .map_err(|()| Error::FunctionNameTooLong {
316            function_name: function.to_string(),
317            length: function.len(),
318        })?;
319
320    let final_args =
321        parsed_args
322            .clone()
323            .try_into()
324            .map_err(|_| Error::MaxNumberOfArgumentsReached {
325                current: parsed_args.len(),
326                maximum: ScVec::default().max_len(),
327            })?;
328
329    Ok(InvokeContractArgs {
330        contract_address: contract_address_arg,
331        function_name: function_symbol_arg,
332        args: final_args,
333    })
334}
335
336pub fn build_custom_cmd(name: &str, spec: &Spec) -> Result<clap::Command, Error> {
337    let func = spec
338        .find_function(name)
339        .map_err(|_| Error::FunctionNotFoundInContractSpec {
340            function_name: name.to_string(),
341            available_functions: get_available_functions(spec),
342        })?;
343
344    // Parse the function arguments
345    let inputs_map = &func
346        .inputs
347        .iter()
348        .map(|i| (sanitize(&i.name.to_utf8_string_lossy()), i.type_.clone()))
349        .collect::<HashMap<String, ScSpecTypeDef>>();
350    let name: &'static str = Box::leak(sanitize(name).into_boxed_str());
351    let mut cmd = clap::Command::new(name)
352        .no_binary_name(true)
353        .term_width(300)
354        .max_term_width(300);
355    let kebab_name = name.to_kebab_case();
356    if kebab_name != name {
357        cmd = cmd.alias(kebab_name);
358    }
359    let doc: &'static str = Box::leak(sanitize(&func.doc.to_utf8_string_lossy()).into_boxed_str());
360    let long_doc: &'static str = Box::leak(arg_file_help(doc).into_boxed_str());
361
362    cmd = cmd.about(Some(doc)).long_about(long_doc);
363    for (name, type_) in inputs_map {
364        let mut arg = clap::Arg::new(name);
365        let file_arg_name = fmt_arg_file_name(name);
366        let mut file_arg = clap::Arg::new(&file_arg_name);
367        arg = arg
368            .long(name)
369            .alias(name.to_kebab_case())
370            .num_args(1)
371            .value_parser(clap::builder::NonEmptyStringValueParser::new())
372            .long_help(
373                spec.doc(name, type_)?
374                    .map(|d| -> &'static str { Box::leak(sanitize(d).into_boxed_str()) }),
375            );
376
377        file_arg = file_arg
378            .long(&file_arg_name)
379            .alias(file_arg_name.to_kebab_case())
380            .num_args(1)
381            .hide(true)
382            .value_parser(value_parser!(PathBuf))
383            .conflicts_with(name);
384
385        if let Some(value_name) = spec.arg_value_name(type_, 0) {
386            let value_name: &'static str = Box::leak(value_name.into_boxed_str());
387            arg = arg.value_name(value_name);
388        }
389
390        // Set up special-case arg rules
391        arg = match type_ {
392            ScSpecTypeDef::Bool => arg
393                .num_args(0..1)
394                .default_missing_value("true")
395                .default_value("false")
396                .num_args(0..=1),
397            ScSpecTypeDef::Option(_val) => arg.required(false),
398            ScSpecTypeDef::I256 | ScSpecTypeDef::I128 | ScSpecTypeDef::I64 | ScSpecTypeDef::I32 => {
399                arg.allow_hyphen_values(true)
400            }
401            _ => arg,
402        };
403
404        cmd = cmd.arg(arg);
405        cmd = cmd.arg(file_arg);
406    }
407    Ok(cmd)
408}
409
410fn fmt_arg_file_name(name: &str) -> String {
411    format!("{name}-file-path")
412}
413
414fn arg_file_help(docs: &str) -> String {
415    format!(
416        r"{docs}
417Usage Notes:
418Each arg has a corresponding --<arg_name>-file-path which is a path to a file containing the corresponding JSON argument.
419Note: The only types which aren't JSON are Bytes and BytesN, which are raw bytes"
420    )
421}
422
423pub fn output_to_string(
424    spec: &Spec,
425    res: &ScVal,
426    function: &str,
427) -> Result<TxnResult<String>, Error> {
428    let mut res_str = String::new();
429    if let Some(output) = spec.find_function(function)?.outputs.first() {
430        res_str = spec
431            .xdr_to_json(res, output)
432            .map_err(|e| Error::CannotPrintResult {
433                result: res.clone(),
434                error: e,
435            })?
436            .to_string();
437    }
438    Ok(TxnResult::Res(res_str))
439}
440
441fn resolve_address(addr_or_alias: &str, config: &config::Args) -> Result<String, Error> {
442    let sc_address: UnresolvedScAddress = addr_or_alias.parse().unwrap();
443    let account = match sc_address {
444        UnresolvedScAddress::Resolved(addr) => addr.to_string(),
445        addr @ UnresolvedScAddress::Alias(_) => {
446            let addr = addr.resolve(
447                &config.locator,
448                &config.get_network()?.network_passphrase,
449                config.hd_path(),
450            )?;
451            match addr {
452                xdr::ScAddress::Account(account) => account.to_string(),
453                contract @ xdr::ScAddress::Contract(_) => contract.to_string(),
454                stellar_xdr::curr::ScAddress::MuxedAccount(account) => account.to_string(),
455                stellar_xdr::curr::ScAddress::ClaimableBalance(_)
456                | stellar_xdr::curr::ScAddress::LiquidityPool(_) => {
457                    return Err(Error::UnsupportedScAddress {
458                        address: addr.to_string(),
459                    })
460                }
461            }
462        }
463    };
464    Ok(account)
465}
466
467fn resolve_signer(addr_or_alias: &str, config: &config::Args) -> Option<Signer> {
468    let secret = config.locator.get_secret_key(addr_or_alias).ok()?;
469    let print = Print::new(false);
470    let signer = secret.signer(config.hd_path(), print).ok()?;
471    Some(signer)
472}
473
474/// Validates JSON string and returns a more descriptive error if invalid
475fn validate_json_arg(arg_name: &str, value: &str) -> Result<(), Error> {
476    // Try to parse as JSON first
477    if let Err(json_err) = serde_json::from_str::<serde_json::Value>(value) {
478        return Err(Error::InvalidJsonArg {
479            arg: arg_name.to_string(),
480            json_error: json_err.to_string(),
481            received_value: value.to_string(),
482        });
483    }
484    Ok(())
485}
486
487/// Gets a human-readable type name for error messages
488fn get_type_name(type_def: &ScSpecTypeDef) -> String {
489    match type_def {
490        ScSpecTypeDef::Val => "any value".to_string(),
491        ScSpecTypeDef::U64 => "u64 (unsigned 64-bit integer)".to_string(),
492        ScSpecTypeDef::I64 => "i64 (signed 64-bit integer)".to_string(),
493        ScSpecTypeDef::U128 => "u128 (unsigned 128-bit integer)".to_string(),
494        ScSpecTypeDef::I128 => "i128 (signed 128-bit integer)".to_string(),
495        ScSpecTypeDef::U32 => "u32 (unsigned 32-bit integer)".to_string(),
496        ScSpecTypeDef::I32 => "i32 (signed 32-bit integer)".to_string(),
497        ScSpecTypeDef::U256 => "u256 (unsigned 256-bit integer)".to_string(),
498        ScSpecTypeDef::I256 => "i256 (signed 256-bit integer)".to_string(),
499        ScSpecTypeDef::Bool => "bool (true/false)".to_string(),
500        ScSpecTypeDef::Symbol => "symbol (identifier)".to_string(),
501        ScSpecTypeDef::String => "string".to_string(),
502        ScSpecTypeDef::Bytes => "bytes (raw binary data)".to_string(),
503        ScSpecTypeDef::BytesN(n) => format!("bytes{} (exactly {} bytes)", n.n, n.n),
504        ScSpecTypeDef::Address => {
505            "address (G... for account, C... for contract, or identity name)".to_string()
506        }
507        ScSpecTypeDef::MuxedAddress => "muxed address (M... or identity name)".to_string(),
508        ScSpecTypeDef::Void => "void (no value)".to_string(),
509        ScSpecTypeDef::Error => "error".to_string(),
510        ScSpecTypeDef::Timepoint => "timepoint (timestamp)".to_string(),
511        ScSpecTypeDef::Duration => "duration (time span)".to_string(),
512        ScSpecTypeDef::Option(inner) => format!("optional {}", get_type_name(&inner.value_type)),
513        ScSpecTypeDef::Vec(inner) => format!("vector of {}", get_type_name(&inner.element_type)),
514        ScSpecTypeDef::Map(map_type) => format!(
515            "map from {} to {}",
516            get_type_name(&map_type.key_type),
517            get_type_name(&map_type.value_type)
518        ),
519        ScSpecTypeDef::Tuple(tuple_type) => {
520            let types: Vec<String> = tuple_type.value_types.iter().map(get_type_name).collect();
521            format!("tuple({})", types.join(", "))
522        }
523        ScSpecTypeDef::Result(_) => "result".to_string(),
524        ScSpecTypeDef::Udt(udt) => {
525            format!(
526                "user-defined type '{}'",
527                sanitize(&udt.name.to_utf8_string_lossy())
528            )
529        }
530    }
531}
532
533/// Gets available function names for error messages
534fn get_available_functions(spec: &Spec) -> String {
535    match spec.find_functions() {
536        Ok(functions) => functions
537            .map(|f| sanitize(&f.name.to_utf8_string_lossy()))
538            .collect::<Vec<_>>()
539            .join(", "),
540        Err(_) => "unknown".to_string(),
541    }
542}
543
544/// Checks if a type is a primitive type that doesn't require JSON validation
545fn is_primitive_type(type_def: &ScSpecTypeDef) -> bool {
546    matches!(
547        type_def,
548        ScSpecTypeDef::U32
549            | ScSpecTypeDef::U64
550            | ScSpecTypeDef::U128
551            | ScSpecTypeDef::U256
552            | ScSpecTypeDef::I32
553            | ScSpecTypeDef::I64
554            | ScSpecTypeDef::I128
555            | ScSpecTypeDef::I256
556            | ScSpecTypeDef::Bool
557            | ScSpecTypeDef::Symbol
558            | ScSpecTypeDef::String
559            | ScSpecTypeDef::Bytes
560            | ScSpecTypeDef::BytesN(_)
561            | ScSpecTypeDef::Address
562            | ScSpecTypeDef::MuxedAddress
563            | ScSpecTypeDef::Timepoint
564            | ScSpecTypeDef::Duration
565            | ScSpecTypeDef::Void
566    )
567}
568
569/// Generates context-aware suggestions based on the expected type and error
570fn get_context_suggestions(expected_type: &ScSpecTypeDef, received_value: &str) -> String {
571    match expected_type {
572        ScSpecTypeDef::U64 | ScSpecTypeDef::I64 | ScSpecTypeDef::U128 | ScSpecTypeDef::I128
573        | ScSpecTypeDef::U32 | ScSpecTypeDef::I32 | ScSpecTypeDef::U256 | ScSpecTypeDef::I256 => {
574            if received_value.starts_with('"') && received_value.ends_with('"') {
575                "For numbers, ensure no quotes around the value (e.g., use 100 instead of \"100\")".to_string()
576            } else if received_value.contains('.') {
577                "Integer types don't support decimal values - use a whole number".to_string()
578            } else {
579                "Ensure the value is a valid integer within the type's range".to_string()
580            }
581        }
582        ScSpecTypeDef::Bool => {
583            "For booleans, use 'true' or 'false' (without quotes)".to_string()
584        }
585        ScSpecTypeDef::String => {
586            if !received_value.starts_with('"') || !received_value.ends_with('"') {
587                "For strings, ensure the value is properly quoted (e.g., \"hello world\")".to_string()
588            } else {
589                "Check for proper string escaping if the string contains special characters".to_string()
590            }
591        }
592        ScSpecTypeDef::Address => {
593            "For addresses, use format: G... (account), C... (contract), or identity name (e.g., alice)".to_string()
594        }
595        ScSpecTypeDef::MuxedAddress => {
596            "For muxed addresses, use format: M... or identity name".to_string()
597        }
598        ScSpecTypeDef::Vec(_) => {
599            "For arrays, use JSON array format: [\"item1\", \"item2\"] or [{\"key\": \"value\"}]".to_string()
600        }
601        ScSpecTypeDef::Map(_) => {
602            "For maps, use JSON object format: {\"key1\": \"value1\", \"key2\": \"value2\"}".to_string()
603        }
604        ScSpecTypeDef::Option(_) => {
605            "For optional values, use null for none or the expected value type".to_string()
606        }
607        _ => {
608            "Check the contract specification for the correct argument format and type".to_string()
609        }
610    }
611}
612
613/// Enhanced argument parsing with better error handling
614fn parse_argument_with_validation(
615    arg_name: &str,
616    value: &str,
617    expected_type: &ScSpecTypeDef,
618    spec: &Spec,
619    config: &config::Args,
620) -> Result<ScVal, Error> {
621    let expected_type_name = get_type_name(expected_type);
622
623    // Pre-validate JSON for non-primitive types, but skip for union (enum) UDTs since
624    // both bare strings (e.g. `Unit`) and JSON strings (e.g. `"Unit"`) are valid for
625    // unit variants — from_string in soroban-spec-tools handles both forms correctly.
626    let is_union_udt = if let ScSpecTypeDef::Udt(udt) = expected_type {
627        spec.find(&udt.name.to_utf8_string_lossy())
628            .is_ok_and(|entry| matches!(entry, ScSpecEntry::UdtUnionV0(_)))
629    } else {
630        false
631    };
632    if !is_primitive_type(expected_type) && !is_union_udt {
633        validate_json_arg(arg_name, value)?;
634    }
635
636    // Walk the input through resolve_aliases_in_json so identity aliases are
637    // resolved at every Address/MuxedAddress position, top-level or nested.
638    let resolved = resolve_aliases(value, expected_type, spec, config)?;
639
640    spec.from_string(&resolved, expected_type)
641        .map_err(|error| Error::CannotParseArg {
642            arg: arg_name.to_string(),
643            error,
644            expected_type: expected_type_name,
645            received_value: value.to_string(),
646            suggestion: get_context_suggestions(expected_type, value),
647        })
648}
649
650/// Returns the input with identity aliases resolved to strkeys at every
651/// `Address`/`MuxedAddress` position the spec describes. Inputs that aren't
652/// JSON (e.g. a bare top-level alias `alice`) are wrapped as a JSON string
653/// for `Address`-typed args so the walker can still resolve them.
654fn resolve_aliases(
655    value: &str,
656    type_def: &ScSpecTypeDef,
657    spec: &Spec,
658    config: &config::Args,
659) -> Result<String, Error> {
660    let is_address = matches!(
661        type_def,
662        ScSpecTypeDef::Address | ScSpecTypeDef::MuxedAddress
663    );
664
665    let mut json = match serde_json::from_str::<serde_json::Value>(value) {
666        Ok(j) => j,
667        Err(_) if is_address => serde_json::Value::String(value.trim_matches('"').to_string()),
668        Err(_) => return Ok(value.to_string()),
669    };
670
671    let mutated = resolve_aliases_in_json(&mut json, type_def, spec, config)?;
672
673    // Nothing was rewritten — return the original input verbatim so we don't
674    // disturb whitespace, key ordering, or number formatting just to reparse it.
675    if !mutated {
676        return Ok(value.to_string());
677    }
678
679    // For top-level Address inputs, hand back the bare strkey rather than a
680    // JSON-quoted form — `Spec::from_string` accepts both, but the bare form
681    // matches what the original Address path produced.
682    Ok(match (&json, is_address) {
683        (serde_json::Value::String(s), true) => s.clone(),
684        _ => json.to_string(),
685    })
686}
687
688/// Walks a JSON value alongside the contract spec type tree, rewriting any
689/// string at an `Address`/`MuxedAddress` position into the resolved address
690/// via the locator. Strings that are already a valid account, contract, or
691/// muxed strkey pass through unchanged.
692///
693/// This makes identity aliases work inside nested arguments (struct fields,
694/// vec/map/tuple elements, option values, union tuple-variant payloads), not
695/// just at the top level. Returns whether any string was actually rewritten.
696fn resolve_aliases_in_json(
697    value: &mut serde_json::Value,
698    type_def: &ScSpecTypeDef,
699    spec: &Spec,
700    config: &config::Args,
701) -> Result<bool, Error> {
702    let mut mutated = false;
703    match type_def {
704        ScSpecTypeDef::Address | ScSpecTypeDef::MuxedAddress => {
705            if let serde_json::Value::String(s) = value {
706                let resolved = resolve_address(s, config)?;
707                if &resolved != s {
708                    *s = resolved;
709                    mutated = true;
710                }
711            }
712        }
713        ScSpecTypeDef::Vec(inner) => {
714            if let serde_json::Value::Array(arr) = value {
715                for item in arr.iter_mut() {
716                    mutated |= resolve_aliases_in_json(item, &inner.element_type, spec, config)?;
717                }
718            }
719        }
720        ScSpecTypeDef::Tuple(tuple) => {
721            if let serde_json::Value::Array(arr) = value {
722                for (item, ty) in arr.iter_mut().zip(tuple.value_types.iter()) {
723                    mutated |= resolve_aliases_in_json(item, ty, spec, config)?;
724                }
725            }
726        }
727        ScSpecTypeDef::Map(map) => {
728            if let serde_json::Value::Object(obj) = value {
729                let key_is_address = matches!(
730                    map.key_type.as_ref(),
731                    ScSpecTypeDef::Address | ScSpecTypeDef::MuxedAddress
732                );
733                if key_is_address {
734                    let entries = std::mem::take(obj);
735                    for (k, mut v) in entries {
736                        mutated |= resolve_aliases_in_json(&mut v, &map.value_type, spec, config)?;
737                        let resolved = resolve_address(&k, config)?;
738                        if resolved != k {
739                            mutated = true;
740                        }
741                        if obj.contains_key(&resolved) {
742                            return Err(Error::DuplicateMapKey { key: resolved });
743                        }
744                        obj.insert(resolved, v);
745                    }
746                } else {
747                    for v in obj.values_mut() {
748                        mutated |= resolve_aliases_in_json(v, &map.value_type, spec, config)?;
749                    }
750                }
751            }
752        }
753        ScSpecTypeDef::Option(inner) if !matches!(value, serde_json::Value::Null) => {
754            mutated |= resolve_aliases_in_json(value, &inner.value_type, spec, config)?;
755        }
756        ScSpecTypeDef::Result(result) => {
757            // Result is rarely used as an input type. The walker descends into
758            // both branches; the inner `match value` no-ops when the JSON
759            // shape doesn't fit the branch's type. Resolution is idempotent
760            // (a strkey re-resolves to itself), so descending twice is safe
761            // when both branches happen to share a shape.
762            mutated |= resolve_aliases_in_json(value, &result.ok_type, spec, config)?;
763            mutated |= resolve_aliases_in_json(value, &result.error_type, spec, config)?;
764        }
765        ScSpecTypeDef::Udt(udt) => {
766            mutated |= resolve_aliases_in_udt(value, udt, spec, config)?;
767        }
768        _ => {}
769    }
770    Ok(mutated)
771}
772
773fn resolve_aliases_in_udt(
774    value: &mut serde_json::Value,
775    udt: &stellar_xdr::curr::ScSpecTypeUdt,
776    spec: &Spec,
777    config: &config::Args,
778) -> Result<bool, Error> {
779    let mut mutated = false;
780    let name = udt.name.to_utf8_string_lossy();
781    let Ok(entry) = spec.find(&name) else {
782        return Ok(false);
783    };
784    match entry {
785        ScSpecEntry::UdtStructV0(strukt) => {
786            // Soroban's contract macros emit numeric field names ("0", "1", …)
787            // for tuple structs and identifier names for regular structs, so a
788            // field literally named "0" reliably distinguishes the two.
789            let is_tuple_struct = strukt
790                .fields
791                .iter()
792                .any(|f| f.name.to_utf8_string_lossy() == "0");
793            match value {
794                serde_json::Value::Array(arr) if is_tuple_struct => {
795                    for (item, field) in arr.iter_mut().zip(strukt.fields.iter()) {
796                        mutated |= resolve_aliases_in_json(item, &field.type_, spec, config)?;
797                    }
798                }
799                serde_json::Value::Object(obj) => {
800                    for field in strukt.fields.iter() {
801                        let key = field.name.to_utf8_string_lossy();
802                        if let Some(field_val) = obj.get_mut(key.as_str()) {
803                            mutated |=
804                                resolve_aliases_in_json(field_val, &field.type_, spec, config)?;
805                        }
806                    }
807                }
808                _ => {}
809            }
810        }
811        ScSpecEntry::UdtUnionV0(union) => {
812            mutated |= resolve_aliases_in_union(value, union, spec, config)?;
813        }
814        _ => {}
815    }
816    Ok(mutated)
817}
818
819fn resolve_aliases_in_union(
820    value: &mut serde_json::Value,
821    union: &stellar_xdr::curr::ScSpecUdtUnionV0,
822    spec: &Spec,
823    config: &config::Args,
824) -> Result<bool, Error> {
825    use stellar_xdr::curr::ScSpecUdtUnionCaseV0;
826
827    let serde_json::Value::Object(obj) = value else {
828        return Ok(false);
829    };
830    let Some((case_name, payload)) = obj.iter_mut().next() else {
831        return Ok(false);
832    };
833    let matched = union.cases.iter().find_map(|c| match c {
834        ScSpecUdtUnionCaseV0::TupleV0(t) if t.name.to_utf8_string_lossy() == *case_name => Some(t),
835        _ => None,
836    });
837    let Some(tuple) = matched else {
838        return Ok(false);
839    };
840    // Single-element tuple variants take a bare payload — `{"Variant": value}` —
841    // matching the form `soroban_spec_tools` accepts. Variants with two or more
842    // elements take an array payload — `{"Variant": [a, b, ...]}`.
843    if tuple.type_.len() == 1 {
844        return resolve_aliases_in_json(payload, &tuple.type_[0], spec, config);
845    }
846    let mut mutated = false;
847    if let serde_json::Value::Array(arr) = payload {
848        for (item, ty) in arr.iter_mut().zip(tuple.type_.iter()) {
849            mutated |= resolve_aliases_in_json(item, ty, spec, config)?;
850        }
851    }
852    Ok(mutated)
853}
854
855#[cfg(test)]
856mod tests {
857    use super::*;
858    use stellar_xdr::curr::{ScSpecTypeBytesN, ScSpecTypeDef, ScSpecTypeOption, ScSpecTypeVec};
859
860    #[test]
861    fn test_get_type_name_primitives() {
862        assert_eq!(
863            get_type_name(&ScSpecTypeDef::U32),
864            "u32 (unsigned 32-bit integer)"
865        );
866        assert_eq!(
867            get_type_name(&ScSpecTypeDef::I64),
868            "i64 (signed 64-bit integer)"
869        );
870        assert_eq!(get_type_name(&ScSpecTypeDef::Bool), "bool (true/false)");
871        assert_eq!(get_type_name(&ScSpecTypeDef::String), "string");
872        assert_eq!(
873            get_type_name(&ScSpecTypeDef::Address),
874            "address (G... for account, C... for contract, or identity name)"
875        );
876    }
877
878    #[test]
879    fn test_get_type_name_complex() {
880        let option_type = ScSpecTypeDef::Option(Box::new(ScSpecTypeOption {
881            value_type: Box::new(ScSpecTypeDef::U32),
882        }));
883        assert_eq!(
884            get_type_name(&option_type),
885            "optional u32 (unsigned 32-bit integer)"
886        );
887
888        let vec_type = ScSpecTypeDef::Vec(Box::new(ScSpecTypeVec {
889            element_type: Box::new(ScSpecTypeDef::String),
890        }));
891        assert_eq!(get_type_name(&vec_type), "vector of string");
892    }
893
894    #[test]
895    fn test_is_primitive_type_all_primitives() {
896        assert!(is_primitive_type(&ScSpecTypeDef::U32));
897        assert!(is_primitive_type(&ScSpecTypeDef::I32));
898        assert!(is_primitive_type(&ScSpecTypeDef::U64));
899        assert!(is_primitive_type(&ScSpecTypeDef::I64));
900        assert!(is_primitive_type(&ScSpecTypeDef::U128));
901        assert!(is_primitive_type(&ScSpecTypeDef::I128));
902        assert!(is_primitive_type(&ScSpecTypeDef::U256));
903        assert!(is_primitive_type(&ScSpecTypeDef::I256));
904
905        assert!(is_primitive_type(&ScSpecTypeDef::Bool));
906        assert!(is_primitive_type(&ScSpecTypeDef::Symbol));
907        assert!(is_primitive_type(&ScSpecTypeDef::String));
908        assert!(is_primitive_type(&ScSpecTypeDef::Void));
909        assert!(is_primitive_type(&ScSpecTypeDef::Bytes));
910        assert!(is_primitive_type(&ScSpecTypeDef::BytesN(
911            ScSpecTypeBytesN { n: 32 }
912        )));
913        assert!(is_primitive_type(&ScSpecTypeDef::BytesN(
914            ScSpecTypeBytesN { n: 64 }
915        )));
916
917        assert!(is_primitive_type(&ScSpecTypeDef::Address));
918        assert!(is_primitive_type(&ScSpecTypeDef::MuxedAddress));
919        assert!(is_primitive_type(&ScSpecTypeDef::Timepoint));
920        assert!(is_primitive_type(&ScSpecTypeDef::Duration));
921
922        assert!(!is_primitive_type(&ScSpecTypeDef::Vec(Box::new(
923            ScSpecTypeVec {
924                element_type: Box::new(ScSpecTypeDef::U32),
925            }
926        ))));
927    }
928
929    #[test]
930    fn test_validate_json_arg_valid() {
931        // Valid JSON should not return an error
932        assert!(validate_json_arg("test_arg", r#"{"key": "value"}"#).is_ok());
933        assert!(validate_json_arg("test_arg", "123").is_ok());
934        assert!(validate_json_arg("test_arg", r#""string""#).is_ok());
935        assert!(validate_json_arg("test_arg", "true").is_ok());
936        assert!(validate_json_arg("test_arg", "null").is_ok());
937    }
938
939    #[test]
940    fn test_validate_json_arg_invalid() {
941        // Invalid JSON should return an error
942        let result = validate_json_arg("test_arg", r#"{"key": value}"#); // Missing quotes around value
943        assert!(result.is_err());
944
945        if let Err(Error::InvalidJsonArg {
946            arg,
947            json_error,
948            received_value,
949        }) = result
950        {
951            assert_eq!(arg, "test_arg");
952            assert_eq!(received_value, r#"{"key": value}"#);
953            assert!(json_error.contains("expected"));
954        } else {
955            panic!("Expected InvalidJsonArg error");
956        }
957    }
958
959    #[test]
960    fn test_validate_json_arg_malformed() {
961        // Test various malformed JSON cases
962        let test_cases = vec![
963            r#"{"key": }"#,         // Missing value
964            r#"{key: "value"}"#,    // Missing quotes around key
965            r#"{"key": "value",}"#, // Trailing comma
966            r#"{"key" "value"}"#,   // Missing colon
967        ];
968
969        for case in test_cases {
970            let result = validate_json_arg("test_arg", case);
971            assert!(result.is_err(), "Expected error for case: {case}");
972        }
973    }
974
975    #[test]
976    fn test_context_aware_error_messages() {
977        use stellar_xdr::curr::ScSpecTypeDef;
978
979        // Test context-aware suggestions for different types
980
981        // Test u64 with quoted value
982        let suggestion = get_context_suggestions(&ScSpecTypeDef::U64, "\"100\"");
983        assert!(suggestion.contains("no quotes around the value"));
984        assert!(suggestion.contains("use 100 instead of \"100\""));
985
986        // Test u64 with decimal value
987        let suggestion = get_context_suggestions(&ScSpecTypeDef::U64, "100.5");
988        assert!(suggestion.contains("don't support decimal values"));
989
990        // Test string without quotes
991        let suggestion = get_context_suggestions(&ScSpecTypeDef::String, "hello");
992        assert!(suggestion.contains("properly quoted"));
993
994        // Test address type
995        let suggestion = get_context_suggestions(&ScSpecTypeDef::Address, "invalid_addr");
996        assert!(suggestion.contains("G... (account), C... (contract)"));
997
998        // Test boolean type
999        let suggestion = get_context_suggestions(&ScSpecTypeDef::Bool, "yes");
1000        assert!(suggestion.contains("'true' or 'false'"));
1001
1002        println!("=== Context-Aware Error Message Examples ===");
1003        println!("U64 with quotes: {suggestion}");
1004
1005        let decimal_suggestion = get_context_suggestions(&ScSpecTypeDef::U64, "100.5");
1006        println!("U64 with decimal: {decimal_suggestion}");
1007
1008        let string_suggestion = get_context_suggestions(&ScSpecTypeDef::String, "hello");
1009        println!("String without quotes: {string_suggestion}");
1010
1011        let address_suggestion = get_context_suggestions(&ScSpecTypeDef::Address, "invalid");
1012        println!("Invalid address: {address_suggestion}");
1013    }
1014
1015    #[test]
1016    fn test_union_udt_bare_string_accepted() {
1017        use stellar_xdr::curr::{
1018            ScSpecEntry, ScSpecTypeDef, ScSpecTypeUdt, ScSpecUdtUnionCaseV0,
1019            ScSpecUdtUnionCaseVoidV0, ScSpecUdtUnionV0, StringM,
1020        };
1021
1022        // Build a minimal Spec with a union type: enum MyEnum { Unit }
1023        let union_name: StringM<60> = "MyEnum".try_into().unwrap();
1024        let case_name: StringM<60> = "Unit".try_into().unwrap();
1025        let spec = Spec(Some(vec![ScSpecEntry::UdtUnionV0(ScSpecUdtUnionV0 {
1026            doc: StringM::default(),
1027            lib: StringM::default(),
1028            name: union_name.clone(),
1029            cases: vec![ScSpecUdtUnionCaseV0::VoidV0(ScSpecUdtUnionCaseVoidV0 {
1030                doc: StringM::default(),
1031                name: case_name,
1032            })]
1033            .try_into()
1034            .unwrap(),
1035        })]));
1036
1037        let expected_type = ScSpecTypeDef::Udt(ScSpecTypeUdt { name: union_name });
1038        let config = crate::config::Args::default();
1039
1040        // Bare string (no JSON quoting) should be accepted
1041        let result =
1042            parse_argument_with_validation("value", "Unit", &expected_type, &spec, &config);
1043        assert!(result.is_ok(), "bare 'Unit' should be accepted: {result:?}");
1044
1045        // JSON-quoted string should also be accepted
1046        let result =
1047            parse_argument_with_validation("value", "\"Unit\"", &expected_type, &spec, &config);
1048        assert!(
1049            result.is_ok(),
1050            "JSON-quoted '\"Unit\"' should be accepted: {result:?}"
1051        );
1052
1053        // Both forms should produce the same ScVal
1054        let bare = parse_argument_with_validation("value", "Unit", &expected_type, &spec, &config)
1055            .unwrap();
1056        let quoted =
1057            parse_argument_with_validation("value", "\"Unit\"", &expected_type, &spec, &config)
1058                .unwrap();
1059        assert_eq!(
1060            bare, quoted,
1061            "bare and quoted forms should produce identical ScVal"
1062        );
1063    }
1064
1065    #[test]
1066    fn test_union_udt_tuple_variant_still_requires_json() {
1067        use stellar_xdr::curr::{
1068            ScSpecEntry, ScSpecTypeDef, ScSpecTypeUdt, ScSpecUdtUnionCaseTupleV0,
1069            ScSpecUdtUnionCaseV0, ScSpecUdtUnionCaseVoidV0, ScSpecUdtUnionV0, StringM,
1070        };
1071
1072        let union_name: StringM<60> = "MyEnum".try_into().unwrap();
1073        let spec = Spec(Some(vec![ScSpecEntry::UdtUnionV0(ScSpecUdtUnionV0 {
1074            doc: StringM::default(),
1075            lib: StringM::default(),
1076            name: union_name.clone(),
1077            cases: vec![
1078                ScSpecUdtUnionCaseV0::VoidV0(ScSpecUdtUnionCaseVoidV0 {
1079                    doc: StringM::default(),
1080                    name: "Unit".try_into().unwrap(),
1081                }),
1082                ScSpecUdtUnionCaseV0::TupleV0(ScSpecUdtUnionCaseTupleV0 {
1083                    doc: StringM::default(),
1084                    name: "WithValue".try_into().unwrap(),
1085                    type_: vec![ScSpecTypeDef::U32].try_into().unwrap(),
1086                }),
1087            ]
1088            .try_into()
1089            .unwrap(),
1090        })]));
1091
1092        let expected_type = ScSpecTypeDef::Udt(ScSpecTypeUdt { name: union_name });
1093        let config = crate::config::Args::default();
1094
1095        // Tuple variant with a value must still use JSON object syntax
1096        let result = parse_argument_with_validation(
1097            "value",
1098            r#"{"WithValue":42}"#,
1099            &expected_type,
1100            &spec,
1101            &config,
1102        );
1103        assert!(
1104            result.is_ok(),
1105            "JSON object for tuple variant should be accepted: {result:?}"
1106        );
1107    }
1108
1109    #[test]
1110    fn test_error_message_format() {
1111        use stellar_xdr::curr::ScSpecTypeDef;
1112
1113        // Test that our CannotParseArg error formats correctly
1114        let error = Error::CannotParseArg {
1115            arg: "amount".to_string(),
1116            error: soroban_spec_tools::Error::InvalidValue(Some(ScSpecTypeDef::U64)),
1117            expected_type: "u64 (unsigned 64-bit integer)".to_string(),
1118            received_value: "\"100\"".to_string(),
1119            suggestion:
1120                "For numbers, ensure no quotes around the value (e.g., use 100 instead of \"100\")"
1121                    .to_string(),
1122        };
1123
1124        let error_message = format!("{error}");
1125        println!("\n=== Complete Error Message Example ===");
1126        println!("{error_message}");
1127
1128        // Verify the error message contains all expected parts
1129        assert!(error_message.contains("Failed to parse argument 'amount'"));
1130        assert!(error_message.contains("Expected type u64 (unsigned 64-bit integer)"));
1131        assert!(error_message.contains("received: '\"100\"'"));
1132        assert!(error_message.contains("Suggestion: For numbers, ensure no quotes"));
1133    }
1134
1135    fn struct_spec(name: &'static str, fields: &[(&str, ScSpecTypeDef)]) -> (Spec, ScSpecTypeDef) {
1136        use stellar_xdr::curr::{
1137            ScSpecEntry, ScSpecTypeUdt, ScSpecUdtStructFieldV0, ScSpecUdtStructV0, StringM,
1138        };
1139        let struct_name: StringM<60> = name.try_into().unwrap();
1140        let fields_xdr: Vec<ScSpecUdtStructFieldV0> = fields
1141            .iter()
1142            .map(|(n, t)| ScSpecUdtStructFieldV0 {
1143                doc: StringM::default(),
1144                name: (*n).try_into().unwrap(),
1145                type_: t.clone(),
1146            })
1147            .collect();
1148        let spec = Spec(Some(vec![ScSpecEntry::UdtStructV0(ScSpecUdtStructV0 {
1149            doc: StringM::default(),
1150            lib: StringM::default(),
1151            name: struct_name.clone(),
1152            fields: fields_xdr.try_into().unwrap(),
1153        })]));
1154        let ty = ScSpecTypeDef::Udt(ScSpecTypeUdt { name: struct_name });
1155        (spec, ty)
1156    }
1157
1158    // A real account strkey that should pass through resolve_address unchanged.
1159    const TEST_G_ADDRESS: &str = "GD5KD2KEZJIGTC63IGW6UMUSMVUVG5IHG64HUTFWCHVZH2N2IBOQN7PS";
1160
1161    #[test]
1162    fn resolve_aliases_in_json_walks_vec_of_address() {
1163        use stellar_xdr::curr::ScSpecTypeVec;
1164
1165        let ty = ScSpecTypeDef::Vec(Box::new(ScSpecTypeVec {
1166            element_type: Box::new(ScSpecTypeDef::Address),
1167        }));
1168        let spec = Spec(Some(vec![]));
1169        let config = crate::config::Args::default();
1170
1171        let mut value = serde_json::json!([TEST_G_ADDRESS]);
1172        resolve_aliases_in_json(&mut value, &ty, &spec, &config).unwrap();
1173        assert_eq!(value, serde_json::json!([TEST_G_ADDRESS]));
1174
1175        // An unknown alias-shaped string at a nested Address position must surface as an error.
1176        let mut value = serde_json::json!(["definitely-not-a-known-alias"]);
1177        let err = resolve_aliases_in_json(&mut value, &ty, &spec, &config).unwrap_err();
1178        assert!(
1179            matches!(err, Error::Config(_) | Error::ScAddress(_)),
1180            "expected alias-resolution error, got {err:?}"
1181        );
1182    }
1183
1184    #[test]
1185    fn resolve_aliases_in_json_walks_tuple() {
1186        use stellar_xdr::curr::ScSpecTypeTuple;
1187
1188        let ty = ScSpecTypeDef::Tuple(Box::new(ScSpecTypeTuple {
1189            value_types: vec![ScSpecTypeDef::Address, ScSpecTypeDef::U32]
1190                .try_into()
1191                .unwrap(),
1192        }));
1193        let spec = Spec(Some(vec![]));
1194        let config = crate::config::Args::default();
1195
1196        let mut value = serde_json::json!([TEST_G_ADDRESS, 42]);
1197        resolve_aliases_in_json(&mut value, &ty, &spec, &config).unwrap();
1198        assert_eq!(value, serde_json::json!([TEST_G_ADDRESS, 42]));
1199
1200        let mut value = serde_json::json!(["bogus-alias", 42]);
1201        let err = resolve_aliases_in_json(&mut value, &ty, &spec, &config).unwrap_err();
1202        assert!(
1203            matches!(err, Error::Config(_) | Error::ScAddress(_)),
1204            "expected alias-resolution error, got {err:?}"
1205        );
1206    }
1207
1208    #[test]
1209    fn resolve_aliases_in_json_walks_struct_field() {
1210        use stellar_xdr::curr::ScSpecTypeVec;
1211
1212        let (spec, ty) = struct_spec(
1213            "Operator",
1214            &[
1215                ("count", ScSpecTypeDef::U32),
1216                (
1217                    "addresses",
1218                    ScSpecTypeDef::Vec(Box::new(ScSpecTypeVec {
1219                        element_type: Box::new(ScSpecTypeDef::Address),
1220                    })),
1221                ),
1222            ],
1223        );
1224        let config = crate::config::Args::default();
1225
1226        let mut value = serde_json::json!({"count": 1, "addresses": [TEST_G_ADDRESS]});
1227        resolve_aliases_in_json(&mut value, &ty, &spec, &config).unwrap();
1228        assert_eq!(
1229            value,
1230            serde_json::json!({"count": 1, "addresses": [TEST_G_ADDRESS]})
1231        );
1232
1233        // Walker must reach the Address inside Vec inside the struct field.
1234        let mut value = serde_json::json!({"count": 1, "addresses": ["bogus-alias"]});
1235        let err = resolve_aliases_in_json(&mut value, &ty, &spec, &config).unwrap_err();
1236        assert!(
1237            matches!(err, Error::Config(_) | Error::ScAddress(_)),
1238            "expected alias-resolution error, got {err:?}"
1239        );
1240    }
1241
1242    #[test]
1243    fn resolve_aliases_in_json_walks_union_tuple_variant() {
1244        use stellar_xdr::curr::{
1245            ScSpecEntry, ScSpecTypeUdt, ScSpecUdtUnionCaseTupleV0, ScSpecUdtUnionCaseV0,
1246            ScSpecUdtUnionV0, StringM,
1247        };
1248
1249        let union_name: StringM<60> = "Choice".try_into().unwrap();
1250        let spec = Spec(Some(vec![ScSpecEntry::UdtUnionV0(ScSpecUdtUnionV0 {
1251            doc: StringM::default(),
1252            lib: StringM::default(),
1253            name: union_name.clone(),
1254            cases: vec![ScSpecUdtUnionCaseV0::TupleV0(ScSpecUdtUnionCaseTupleV0 {
1255                doc: StringM::default(),
1256                name: "Pick".try_into().unwrap(),
1257                type_: vec![ScSpecTypeDef::Address, ScSpecTypeDef::U32]
1258                    .try_into()
1259                    .unwrap(),
1260            })]
1261            .try_into()
1262            .unwrap(),
1263        })]));
1264
1265        let ty = ScSpecTypeDef::Udt(ScSpecTypeUdt { name: union_name });
1266        let config = crate::config::Args::default();
1267
1268        let mut value = serde_json::json!({"Pick": [TEST_G_ADDRESS, 42]});
1269        resolve_aliases_in_json(&mut value, &ty, &spec, &config).unwrap();
1270        assert_eq!(value, serde_json::json!({"Pick": [TEST_G_ADDRESS, 42]}));
1271
1272        let mut value = serde_json::json!({"Pick": ["bogus-alias", 42]});
1273        let err = resolve_aliases_in_json(&mut value, &ty, &spec, &config).unwrap_err();
1274        assert!(
1275            matches!(err, Error::Config(_) | Error::ScAddress(_)),
1276            "expected alias-resolution error, got {err:?}"
1277        );
1278    }
1279
1280    #[test]
1281    fn resolve_aliases_in_json_walks_single_element_union_variant() {
1282        use stellar_xdr::curr::{
1283            ScSpecEntry, ScSpecTypeUdt, ScSpecUdtUnionCaseTupleV0, ScSpecUdtUnionCaseV0,
1284            ScSpecUdtUnionV0, StringM,
1285        };
1286
1287        let union_name: StringM<60> = "OneOf".try_into().unwrap();
1288        let spec = Spec(Some(vec![ScSpecEntry::UdtUnionV0(ScSpecUdtUnionV0 {
1289            doc: StringM::default(),
1290            lib: StringM::default(),
1291            name: union_name.clone(),
1292            cases: vec![ScSpecUdtUnionCaseV0::TupleV0(ScSpecUdtUnionCaseTupleV0 {
1293                doc: StringM::default(),
1294                name: "Only".try_into().unwrap(),
1295                type_: vec![ScSpecTypeDef::Address].try_into().unwrap(),
1296            })]
1297            .try_into()
1298            .unwrap(),
1299        })]));
1300
1301        let ty = ScSpecTypeDef::Udt(ScSpecTypeUdt { name: union_name });
1302        let config = crate::config::Args::default();
1303
1304        // Bare payload form: {"Only": addr} — not {"Only": [addr]}.
1305        let mut value = serde_json::json!({"Only": TEST_G_ADDRESS});
1306        resolve_aliases_in_json(&mut value, &ty, &spec, &config).unwrap();
1307        assert_eq!(value, serde_json::json!({"Only": TEST_G_ADDRESS}));
1308
1309        let mut value = serde_json::json!({"Only": "bogus-alias"});
1310        let err = resolve_aliases_in_json(&mut value, &ty, &spec, &config).unwrap_err();
1311        assert!(
1312            matches!(err, Error::Config(_) | Error::ScAddress(_)),
1313            "expected alias-resolution error, got {err:?}"
1314        );
1315    }
1316
1317    #[test]
1318    fn resolve_aliases_in_json_walks_option_and_map() {
1319        use stellar_xdr::curr::{ScSpecTypeMap, ScSpecTypeOption};
1320
1321        let opt_ty = ScSpecTypeDef::Option(Box::new(ScSpecTypeOption {
1322            value_type: Box::new(ScSpecTypeDef::Address),
1323        }));
1324        let spec = Spec(Some(vec![]));
1325        let config = crate::config::Args::default();
1326
1327        let mut value = serde_json::Value::Null;
1328        resolve_aliases_in_json(&mut value, &opt_ty, &spec, &config).unwrap();
1329        assert_eq!(value, serde_json::Value::Null);
1330
1331        let mut value = serde_json::json!(TEST_G_ADDRESS);
1332        resolve_aliases_in_json(&mut value, &opt_ty, &spec, &config).unwrap();
1333        assert_eq!(value, serde_json::json!(TEST_G_ADDRESS));
1334
1335        let map_ty = ScSpecTypeDef::Map(Box::new(ScSpecTypeMap {
1336            key_type: Box::new(ScSpecTypeDef::Symbol),
1337            value_type: Box::new(ScSpecTypeDef::Address),
1338        }));
1339        let mut value = serde_json::json!({"owner": TEST_G_ADDRESS});
1340        resolve_aliases_in_json(&mut value, &map_ty, &spec, &config).unwrap();
1341        assert_eq!(value, serde_json::json!({"owner": TEST_G_ADDRESS}));
1342
1343        let mut value = serde_json::json!({"owner": "bogus-alias"});
1344        let err = resolve_aliases_in_json(&mut value, &map_ty, &spec, &config).unwrap_err();
1345        assert!(
1346            matches!(err, Error::Config(_) | Error::ScAddress(_)),
1347            "expected alias-resolution error, got {err:?}"
1348        );
1349    }
1350
1351    #[test]
1352    fn resolve_aliases_in_json_walks_result_inner_types() {
1353        use stellar_xdr::curr::ScSpecTypeResult;
1354
1355        let ty = ScSpecTypeDef::Result(Box::new(ScSpecTypeResult {
1356            ok_type: Box::new(ScSpecTypeDef::Address),
1357            error_type: Box::new(ScSpecTypeDef::U32),
1358        }));
1359        let spec = Spec(Some(vec![]));
1360        let config = crate::config::Args::default();
1361
1362        let mut value = serde_json::json!(TEST_G_ADDRESS);
1363        resolve_aliases_in_json(&mut value, &ty, &spec, &config).unwrap();
1364        assert_eq!(value, serde_json::json!(TEST_G_ADDRESS));
1365
1366        let mut value = serde_json::json!("bogus-alias");
1367        let err = resolve_aliases_in_json(&mut value, &ty, &spec, &config).unwrap_err();
1368        assert!(
1369            matches!(err, Error::Config(_) | Error::ScAddress(_)),
1370            "expected alias-resolution error, got {err:?}"
1371        );
1372    }
1373
1374    #[test]
1375    fn resolve_aliases_preserves_input_when_nothing_mutated() {
1376        use stellar_xdr::curr::ScSpecTypeVec;
1377
1378        // Type with no Address positions: input is returned verbatim,
1379        // including whitespace that compact JSON re-serialization would drop.
1380        let (spec, ty) = struct_spec(
1381            "Point",
1382            &[("x", ScSpecTypeDef::U32), ("y", ScSpecTypeDef::U32)],
1383        );
1384        let config = crate::config::Args::default();
1385        let pretty = r#"{ "x": 1, "y": 2 }"#;
1386        assert_eq!(
1387            resolve_aliases(pretty, &ty, &spec, &config).unwrap(),
1388            pretty
1389        );
1390
1391        // Type with Address positions but no aliases: also returned verbatim.
1392        let ty = ScSpecTypeDef::Vec(Box::new(ScSpecTypeVec {
1393            element_type: Box::new(ScSpecTypeDef::Address),
1394        }));
1395        let spec = Spec(Some(vec![]));
1396        let pretty = format!(r#"[ "{TEST_G_ADDRESS}" ]"#);
1397        assert_eq!(
1398            resolve_aliases(&pretty, &ty, &spec, &config).unwrap(),
1399            pretty
1400        );
1401    }
1402
1403    #[test]
1404    fn resolve_aliases_in_json_walks_map_keys() {
1405        use stellar_xdr::curr::ScSpecTypeMap;
1406
1407        let map_ty = ScSpecTypeDef::Map(Box::new(ScSpecTypeMap {
1408            key_type: Box::new(ScSpecTypeDef::Address),
1409            value_type: Box::new(ScSpecTypeDef::U32),
1410        }));
1411        let spec = Spec(Some(vec![]));
1412        let config = crate::config::Args::default();
1413
1414        let mut value = serde_json::json!({ TEST_G_ADDRESS: 1 });
1415        resolve_aliases_in_json(&mut value, &map_ty, &spec, &config).unwrap();
1416        assert_eq!(value, serde_json::json!({ TEST_G_ADDRESS: 1 }));
1417
1418        let mut value = serde_json::json!({ "bogus-alias": 1 });
1419        let err = resolve_aliases_in_json(&mut value, &map_ty, &spec, &config).unwrap_err();
1420        assert!(
1421            matches!(err, Error::Config(_) | Error::ScAddress(_)),
1422            "expected alias-resolution error, got {err:?}"
1423        );
1424    }
1425
1426    /// Mirrors `stellar contract invoke`: Spec::from_wasm -> build_clap_command -> render_long_help.
1427    #[test]
1428    fn invoke_help_strips_control_characters() {
1429        let path = concat!(
1430            env!("CARGO_MANIFEST_DIR"),
1431            "/../crates/soroban-spec-tools/tests/fixtures/control_characters.wasm"
1432        );
1433        let bytes = std::fs::read(path).expect("fixture wasm should be readable");
1434        let spec = Spec::from_wasm(&bytes).expect("wasm should parse without error");
1435        let mut cmd = build_clap_command(&spec, true).expect("command should build without error");
1436        let help = cmd.render_long_help().to_string();
1437
1438        let bad_chars: Vec<char> = help
1439            .chars()
1440            .filter(|c| c.is_control() && *c != '\n' && *c != '\t')
1441            .collect();
1442        assert!(
1443            bad_chars.is_empty(),
1444            "invoke help contains unexpected control characters {bad_chars:?}:\n{help:?}"
1445        );
1446    }
1447}