From e32a88ab67325a27863b8fbf02e5ac0c186e34fd Mon Sep 17 00:00:00 2001 From: Leigh <351529+leighmcculloch@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:16:39 +0000 Subject: [PATCH 1/6] reduce qualified udt names in contract info output --- cmd/crates/soroban-spec-tools/src/lib.rs | 1 + cmd/crates/soroban-spec-tools/src/reduce.rs | 465 ++++++++++++++++++ .../src/commands/contract/info/interface.rs | 34 +- 3 files changed, 497 insertions(+), 3 deletions(-) create mode 100644 cmd/crates/soroban-spec-tools/src/reduce.rs diff --git a/cmd/crates/soroban-spec-tools/src/lib.rs b/cmd/crates/soroban-spec-tools/src/lib.rs index 29beda432d..426fb46e82 100644 --- a/cmd/crates/soroban-spec-tools/src/lib.rs +++ b/cmd/crates/soroban-spec-tools/src/lib.rs @@ -17,6 +17,7 @@ use stellar_xdr::{ pub mod contract; pub mod event; +pub mod reduce; pub mod test_utils; pub mod utils; mod verify; diff --git a/cmd/crates/soroban-spec-tools/src/reduce.rs b/cmd/crates/soroban-spec-tools/src/reduce.rs new file mode 100644 index 0000000000..0c0de1beb4 --- /dev/null +++ b/cmd/crates/soroban-spec-tools/src/reduce.rs @@ -0,0 +1,465 @@ +//! Reduce the fully qualified user-defined type names that appear in a contract +//! spec down to short, human-friendly names for display and code generation. +//! +//! Contract specs built with a soroban-sdk that names user-defined types by +//! their fully qualified Rust path (see +//! ) carry names such as +//! `my_contract::inner::State`. Those names are unambiguous but noisy, and the +//! `::` separator is not a valid identifier, so the Rust and TypeScript binding +//! generators cannot use them verbatim. This module rewrites every user-defined +//! type name — both where the type is declared and everywhere it is referenced +//! — to its final path segment (`State`). When two distinct qualified names +//! reduce to the same short name they are disambiguated with a numeric suffix +//! (`State`, `State1`), and the collision is reported so a caller can warn. + +use std::collections::{BTreeMap, HashSet}; + +use stellar_xdr::{ + ScSpecEntry, ScSpecEventV0, ScSpecFunctionV0, ScSpecTypeDef, ScSpecTypeMap, ScSpecTypeOption, + ScSpecTypeResult, ScSpecTypeTuple, ScSpecTypeUdt, ScSpecTypeVec, ScSpecUdtEnumV0, + ScSpecUdtErrorEnumV0, ScSpecUdtStructV0, ScSpecUdtUnionCaseV0, ScSpecUdtUnionV0, +}; + +/// A single user-defined type name that was rewritten during reduction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rename { + /// The original, fully qualified name (e.g. `my_contract::inner::State`). + pub from: String, + /// The reduced name it was rewritten to (e.g. `State` or `State1`). + pub to: String, +} + +/// A group of distinct qualified names that share the same short name and so +/// had to be disambiguated with numeric suffixes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Collision { + /// The short name they all reduced to before suffixing (e.g. `State`). + pub short: String, + /// The colliding members, each with the suffixed name it received. + pub members: Vec, +} + +/// The record of what `reduce_udt_names` changed. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Reduction { + /// Every type whose name changed, in declaration order. + pub renames: Vec, + /// The subset of renames that were forced to a numeric suffix because + /// another qualified type reduced to the same short name. + pub collisions: Vec, +} + +impl Reduction { + /// Whether any name was rewritten. + #[must_use] + pub fn is_empty(&self) -> bool { + self.renames.is_empty() + } +} + +/// The final path segment of a qualified name, i.e. the part after the last +/// `::`. Names without a `::` are returned unchanged. +fn short_name(name: &str) -> &str { + name.rsplit("::").next().unwrap_or(name) +} + +/// Reduce the fully qualified user-defined type names in `spec` to short names, +/// returning the rewritten spec alongside a report of what changed. A spec that +/// already uses short names (no `::`) is returned unchanged with an empty +/// [`Reduction`]. +#[must_use] +pub fn reduce_udt_names(spec: &[ScSpecEntry]) -> (Vec, Reduction) { + let declared = declared_udt_names(spec); + let (map, reduction) = build_mapping(&declared); + + let reduced = spec.iter().map(|e| rewrite_entry(e, &map)).collect(); + (reduced, reduction) +} + +/// The names of every user-defined type declared in the spec, in declaration +/// order and de-duplicated. +fn declared_udt_names(spec: &[ScSpecEntry]) -> Vec { + let mut names = Vec::new(); + let mut seen = HashSet::new(); + let mut push = |name: String| { + if seen.insert(name.clone()) { + names.push(name); + } + }; + for entry in spec { + match entry { + ScSpecEntry::UdtStructV0(ScSpecUdtStructV0 { name, .. }) + | ScSpecEntry::UdtUnionV0(ScSpecUdtUnionV0 { name, .. }) + | ScSpecEntry::UdtEnumV0(ScSpecUdtEnumV0 { name, .. }) + | ScSpecEntry::UdtErrorEnumV0(ScSpecUdtErrorEnumV0 { name, .. }) => { + push(name.to_utf8_string_lossy()); + } + ScSpecEntry::FunctionV0(_) | ScSpecEntry::EventV0(_) => {} + } + } + names +} + +/// Build the rename map from qualified name to reduced name, grouping by short +/// name so collisions can be disambiguated deterministically. +fn build_mapping(declared: &[String]) -> (BTreeMap, Reduction) { + // Group the declared names by their short name. `BTreeMap`/sorted members + // keep suffix assignment stable across runs. + let mut groups: BTreeMap<&str, Vec<&String>> = BTreeMap::new(); + for name in declared { + groups.entry(short_name(name)).or_default().push(name); + } + for members in groups.values_mut() { + members.sort(); + } + + let mut map = BTreeMap::new(); + let mut renames = Vec::new(); + let mut collisions = Vec::new(); + // Every reduced name handed out, so a numeric suffix never lands on a name + // already taken by another (possibly unrelated) type. + let mut used: HashSet = HashSet::new(); + + for (short, members) in &groups { + let collision = members.len() > 1; + let mut collision_members = Vec::new(); + for (i, full) in members.iter().enumerate() { + let mut n = i; + let mut candidate = if i == 0 { + (*short).to_string() + } else { + format!("{short}{n}") + }; + while used.contains(&candidate) { + n += 1; + candidate = format!("{short}{n}"); + } + used.insert(candidate.clone()); + map.insert((*full).clone(), candidate.clone()); + if **full != candidate { + let rename = Rename { + from: (*full).clone(), + to: candidate.clone(), + }; + renames.push(rename.clone()); + if collision { + collision_members.push(rename); + } + } else if collision { + collision_members.push(Rename { + from: (*full).clone(), + to: candidate, + }); + } + } + if collision { + collisions.push(Collision { + short: (*short).to_string(), + members: collision_members, + }); + } + } + + // Report renames in the spec's declaration order rather than sorted order. + renames.sort_by_key(|r| { + declared + .iter() + .position(|d| *d == r.from) + .unwrap_or(usize::MAX) + }); + + ( + map, + Reduction { + renames, + collisions, + }, + ) +} + +/// Look up the reduced name for a referenced type. A reference to a declared +/// type resolves through the map; a stray qualified name that was never +/// declared still gets its `::` stripped so no invalid identifier leaks into +/// the output. +fn reduced_ref(name: &str, map: &BTreeMap) -> String { + map.get(name) + .cloned() + .unwrap_or_else(|| short_name(name).to_string()) +} + +fn rewrite_entry(entry: &ScSpecEntry, map: &BTreeMap) -> ScSpecEntry { + match entry { + ScSpecEntry::UdtStructV0(s) => { + let mut s = s.clone(); + s.name = rename_udt(&s.name.to_utf8_string_lossy(), map); + s.fields = s + .fields + .iter() + .map(|f| { + let mut f = f.clone(); + f.type_ = rewrite_type(&f.type_, map); + f + }) + .collect::>() + .try_into() + .unwrap_or(s.fields); + ScSpecEntry::UdtStructV0(s) + } + ScSpecEntry::UdtUnionV0(u) => { + let mut u = u.clone(); + u.name = rename_udt(&u.name.to_utf8_string_lossy(), map); + u.cases = u + .cases + .iter() + .map(|c| rewrite_union_case(c, map)) + .collect::>() + .try_into() + .unwrap_or(u.cases); + ScSpecEntry::UdtUnionV0(u) + } + ScSpecEntry::UdtEnumV0(e) => { + let mut e = e.clone(); + e.name = rename_udt(&e.name.to_utf8_string_lossy(), map); + ScSpecEntry::UdtEnumV0(e) + } + ScSpecEntry::UdtErrorEnumV0(e) => { + let mut e = e.clone(); + e.name = rename_udt(&e.name.to_utf8_string_lossy(), map); + ScSpecEntry::UdtErrorEnumV0(e) + } + ScSpecEntry::FunctionV0(f) => { + let ScSpecFunctionV0 { + doc, + name, + inputs, + outputs, + } = f; + let mut f = ScSpecFunctionV0 { + doc: doc.clone(), + name: name.clone(), + inputs: inputs.clone(), + outputs: outputs.clone(), + }; + f.inputs = f + .inputs + .iter() + .map(|i| { + let mut i = i.clone(); + i.type_ = rewrite_type(&i.type_, map); + i + }) + .collect::>() + .try_into() + .unwrap_or(f.inputs); + f.outputs = f + .outputs + .iter() + .map(|o| rewrite_type(o, map)) + .collect::>() + .try_into() + .unwrap_or(f.outputs); + ScSpecEntry::FunctionV0(f) + } + ScSpecEntry::EventV0(e) => { + let ScSpecEventV0 { .. } = e; + let mut e = e.clone(); + e.params = e + .params + .iter() + .map(|p| { + let mut p = p.clone(); + p.type_ = rewrite_type(&p.type_, map); + p + }) + .collect::>() + .try_into() + .unwrap_or(e.params); + ScSpecEntry::EventV0(e) + } + } +} + +fn rewrite_union_case( + case: &ScSpecUdtUnionCaseV0, + map: &BTreeMap, +) -> ScSpecUdtUnionCaseV0 { + match case { + ScSpecUdtUnionCaseV0::VoidV0(_) => case.clone(), + ScSpecUdtUnionCaseV0::TupleV0(t) => { + let mut t = t.clone(); + t.type_ = t + .type_ + .iter() + .map(|ty| rewrite_type(ty, map)) + .collect::>() + .try_into() + .unwrap_or(t.type_); + ScSpecUdtUnionCaseV0::TupleV0(t) + } + } +} + +/// Rewrite a type, recursing into every place a user-defined type can be +/// referenced. +fn rewrite_type(ty: &ScSpecTypeDef, map: &BTreeMap) -> ScSpecTypeDef { + match ty { + ScSpecTypeDef::Udt(ScSpecTypeUdt { name }) => ScSpecTypeDef::Udt(ScSpecTypeUdt { + name: rename_udt(&name.to_utf8_string_lossy(), map), + }), + ScSpecTypeDef::Option(o) => ScSpecTypeDef::Option(Box::new(ScSpecTypeOption { + value_type: Box::new(rewrite_type(&o.value_type, map)), + })), + ScSpecTypeDef::Result(r) => ScSpecTypeDef::Result(Box::new(ScSpecTypeResult { + ok_type: Box::new(rewrite_type(&r.ok_type, map)), + error_type: Box::new(rewrite_type(&r.error_type, map)), + })), + ScSpecTypeDef::Vec(v) => ScSpecTypeDef::Vec(Box::new(ScSpecTypeVec { + element_type: Box::new(rewrite_type(&v.element_type, map)), + })), + ScSpecTypeDef::Map(m) => ScSpecTypeDef::Map(Box::new(ScSpecTypeMap { + key_type: Box::new(rewrite_type(&m.key_type, map)), + value_type: Box::new(rewrite_type(&m.value_type, map)), + })), + ScSpecTypeDef::Tuple(t) => ScSpecTypeDef::Tuple(Box::new(ScSpecTypeTuple { + value_types: t + .value_types + .iter() + .map(|vt| rewrite_type(vt, map)) + .collect::>() + .try_into() + .unwrap_or_else(|_| t.value_types.clone()), + })), + other => other.clone(), + } +} + +/// Rewrite a declaration or reference name to its reduced form as a +/// `StringM<256>`. The reduced name is never longer than the original, so it +/// always fits. +fn rename_udt( + name: &str, + map: &BTreeMap, +) -> stellar_xdr::StringM<{ crate::UDT_NAME_LIMIT }> { + reduced_ref(name, map) + .try_into() + .unwrap_or_else(|_| name.try_into().unwrap_or_default()) +} + +#[cfg(test)] +mod tests { + use super::*; + use stellar_xdr::{ + ScSpecFunctionInputV0, ScSpecTypeUdt, ScSpecUdtEnumV0, ScSpecUdtStructFieldV0, + ScSpecUdtStructV0, StringM, VecM, + }; + + fn udt(name: &str) -> ScSpecTypeDef { + ScSpecTypeDef::Udt(ScSpecTypeUdt { + name: name.try_into().unwrap(), + }) + } + + fn struct_entry(name: &str, field_type: ScSpecTypeDef) -> ScSpecEntry { + ScSpecEntry::UdtStructV0(ScSpecUdtStructV0 { + doc: StringM::default(), + lib: StringM::default(), + name: name.try_into().unwrap(), + fields: vec![ScSpecUdtStructFieldV0 { + doc: StringM::default(), + name: "f".try_into().unwrap(), + type_: field_type, + }] + .try_into() + .unwrap(), + }) + } + + fn enum_entry(name: &str) -> ScSpecEntry { + ScSpecEntry::UdtEnumV0(ScSpecUdtEnumV0 { + doc: StringM::default(), + lib: StringM::default(), + name: name.try_into().unwrap(), + cases: VecM::default(), + }) + } + + fn fn_entry(name: &str, input_type: ScSpecTypeDef) -> ScSpecEntry { + ScSpecEntry::FunctionV0(ScSpecFunctionV0 { + doc: StringM::default(), + name: name.try_into().unwrap(), + inputs: vec![ScSpecFunctionInputV0 { + doc: StringM::default(), + name: "arg".try_into().unwrap(), + type_: input_type, + }] + .try_into() + .unwrap(), + outputs: VecM::default(), + }) + } + + fn entry_name(entry: &ScSpecEntry) -> String { + match entry { + ScSpecEntry::UdtStructV0(s) => s.name.to_utf8_string_lossy(), + ScSpecEntry::UdtUnionV0(u) => u.name.to_utf8_string_lossy(), + ScSpecEntry::UdtEnumV0(e) => e.name.to_utf8_string_lossy(), + ScSpecEntry::UdtErrorEnumV0(e) => e.name.to_utf8_string_lossy(), + _ => String::new(), + } + } + + #[test] + fn no_qualified_names_is_a_noop() { + let spec = vec![enum_entry("State"), struct_entry("Point", udt("State"))]; + let (reduced, report) = reduce_udt_names(&spec); + assert!(report.is_empty()); + assert_eq!(reduced, spec); + } + + #[test] + fn shortens_and_rewrites_references() { + let spec = vec![ + enum_entry("my_contract::inner::State"), + fn_entry("run", udt("my_contract::inner::State")), + ]; + let (reduced, report) = reduce_udt_names(&spec); + + assert_eq!(entry_name(&reduced[0]), "State"); + // The reference inside the function input is rewritten too. + let ScSpecEntry::FunctionV0(f) = &reduced[1] else { + panic!("expected function") + }; + assert_eq!(f.inputs[0].type_, udt("State")); + + assert_eq!(report.renames.len(), 1); + assert_eq!(report.renames[0].from, "my_contract::inner::State"); + assert_eq!(report.renames[0].to, "State"); + assert!(report.collisions.is_empty()); + } + + #[test] + fn disambiguates_collisions_with_suffixes() { + let spec = vec![ + enum_entry("my_contract::a::Status"), + enum_entry("my_contract::b::Status"), + fn_entry("run", udt("my_contract::b::Status")), + ]; + let (reduced, report) = reduce_udt_names(&spec); + + // Deterministic: `a::Status` sorts first and keeps the bare name. + assert_eq!(entry_name(&reduced[0]), "Status"); + assert_eq!(entry_name(&reduced[1]), "Status1"); + // The reference to `b::Status` follows its rename to `Status1`. + let ScSpecEntry::FunctionV0(f) = &reduced[2] else { + panic!("expected function") + }; + assert_eq!(f.inputs[0].type_, udt("Status1")); + + assert_eq!(report.collisions.len(), 1); + let collision = &report.collisions[0]; + assert_eq!(collision.short, "Status"); + assert_eq!(collision.members.len(), 2); + assert_eq!(collision.members[0].to, "Status"); + assert_eq!(collision.members[1].to, "Status1"); + } +} diff --git a/cmd/soroban-cli/src/commands/contract/info/interface.rs b/cmd/soroban-cli/src/commands/contract/info/interface.rs index a2dfa1de38..7a5f16c1b9 100644 --- a/cmd/soroban-cli/src/commands/contract/info/interface.rs +++ b/cmd/soroban-cli/src/commands/contract/info/interface.rs @@ -65,17 +65,45 @@ impl Cmd { } }; + // Contract specs may name user-defined types by their fully qualified + // path (e.g. `my_contract::inner::State`). Those names are noisy and, + // because `::` is not a valid identifier, the Rust and JSON renderers + // below cannot use them as-is. Reduce them to short names for display, + // reporting what changed. The `XdrBase64` output is the canonical + // on-chain spec, so it is left untouched. + let (reduced_spec, reduction) = soroban_spec_tools::reduce::reduce_udt_names(&spec); + if !matches!(self.output, InfoOutput::XdrBase64) { + for rename in &reduction.renames { + print.infoln(format!( + "Reduced type name {} to {}", + rename.from, rename.to + )); + } + if !reduction.collisions.is_empty() { + use std::fmt::Write as _; + let mut msg = String::from( + "Reduced type names collided and were disambiguated with a numeric suffix:", + ); + for collision in &reduction.collisions { + for member in &collision.members { + let _ = write!(msg, "\n {} -> {}", member.from, member.to); + } + } + print.warnln(msg); + } + } + let res = match self.output { InfoOutput::XdrBase64 => base64, - InfoOutput::Json => serde_json::to_string(&spec)?, - InfoOutput::JsonFormatted => serde_json::to_string_pretty(&spec)?, + InfoOutput::Json => serde_json::to_string(&reduced_spec)?, + InfoOutput::JsonFormatted => serde_json::to_string_pretty(&reduced_spec)?, // soroban_spec_rust drops doc strings entirely (rustdocs can execute // code) and routes every spec name through `format_ident!`, which // rejects non-identifier bytes. If a future revision starts // emitting spec strings as `Literal::string` or rustdocs, this // path becomes a terminal-escape-injection vector and must be // sanitized before printing. - InfoOutput::Rust => soroban_spec_rust::generate_without_file(&spec)? + InfoOutput::Rust => soroban_spec_rust::generate_without_file(&reduced_spec)? .to_formatted_string() .expect("Unexpected spec format error"), }; From 14dd574a9b86c684f0142dd9c0194b9527b916eb Mon Sep 17 00:00:00 2001 From: Leigh <351529+leighmcculloch@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:56:01 +0000 Subject: [PATCH 2/6] use soroban-spec reduce instead of cli duplicate --- cmd/crates/soroban-spec-tools/src/lib.rs | 1 - cmd/crates/soroban-spec-tools/src/reduce.rs | 465 ------------------ .../src/commands/contract/info/interface.rs | 22 +- 3 files changed, 14 insertions(+), 474 deletions(-) delete mode 100644 cmd/crates/soroban-spec-tools/src/reduce.rs diff --git a/cmd/crates/soroban-spec-tools/src/lib.rs b/cmd/crates/soroban-spec-tools/src/lib.rs index 426fb46e82..29beda432d 100644 --- a/cmd/crates/soroban-spec-tools/src/lib.rs +++ b/cmd/crates/soroban-spec-tools/src/lib.rs @@ -17,7 +17,6 @@ use stellar_xdr::{ pub mod contract; pub mod event; -pub mod reduce; pub mod test_utils; pub mod utils; mod verify; diff --git a/cmd/crates/soroban-spec-tools/src/reduce.rs b/cmd/crates/soroban-spec-tools/src/reduce.rs deleted file mode 100644 index 0c0de1beb4..0000000000 --- a/cmd/crates/soroban-spec-tools/src/reduce.rs +++ /dev/null @@ -1,465 +0,0 @@ -//! Reduce the fully qualified user-defined type names that appear in a contract -//! spec down to short, human-friendly names for display and code generation. -//! -//! Contract specs built with a soroban-sdk that names user-defined types by -//! their fully qualified Rust path (see -//! ) carry names such as -//! `my_contract::inner::State`. Those names are unambiguous but noisy, and the -//! `::` separator is not a valid identifier, so the Rust and TypeScript binding -//! generators cannot use them verbatim. This module rewrites every user-defined -//! type name — both where the type is declared and everywhere it is referenced -//! — to its final path segment (`State`). When two distinct qualified names -//! reduce to the same short name they are disambiguated with a numeric suffix -//! (`State`, `State1`), and the collision is reported so a caller can warn. - -use std::collections::{BTreeMap, HashSet}; - -use stellar_xdr::{ - ScSpecEntry, ScSpecEventV0, ScSpecFunctionV0, ScSpecTypeDef, ScSpecTypeMap, ScSpecTypeOption, - ScSpecTypeResult, ScSpecTypeTuple, ScSpecTypeUdt, ScSpecTypeVec, ScSpecUdtEnumV0, - ScSpecUdtErrorEnumV0, ScSpecUdtStructV0, ScSpecUdtUnionCaseV0, ScSpecUdtUnionV0, -}; - -/// A single user-defined type name that was rewritten during reduction. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Rename { - /// The original, fully qualified name (e.g. `my_contract::inner::State`). - pub from: String, - /// The reduced name it was rewritten to (e.g. `State` or `State1`). - pub to: String, -} - -/// A group of distinct qualified names that share the same short name and so -/// had to be disambiguated with numeric suffixes. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Collision { - /// The short name they all reduced to before suffixing (e.g. `State`). - pub short: String, - /// The colliding members, each with the suffixed name it received. - pub members: Vec, -} - -/// The record of what `reduce_udt_names` changed. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct Reduction { - /// Every type whose name changed, in declaration order. - pub renames: Vec, - /// The subset of renames that were forced to a numeric suffix because - /// another qualified type reduced to the same short name. - pub collisions: Vec, -} - -impl Reduction { - /// Whether any name was rewritten. - #[must_use] - pub fn is_empty(&self) -> bool { - self.renames.is_empty() - } -} - -/// The final path segment of a qualified name, i.e. the part after the last -/// `::`. Names without a `::` are returned unchanged. -fn short_name(name: &str) -> &str { - name.rsplit("::").next().unwrap_or(name) -} - -/// Reduce the fully qualified user-defined type names in `spec` to short names, -/// returning the rewritten spec alongside a report of what changed. A spec that -/// already uses short names (no `::`) is returned unchanged with an empty -/// [`Reduction`]. -#[must_use] -pub fn reduce_udt_names(spec: &[ScSpecEntry]) -> (Vec, Reduction) { - let declared = declared_udt_names(spec); - let (map, reduction) = build_mapping(&declared); - - let reduced = spec.iter().map(|e| rewrite_entry(e, &map)).collect(); - (reduced, reduction) -} - -/// The names of every user-defined type declared in the spec, in declaration -/// order and de-duplicated. -fn declared_udt_names(spec: &[ScSpecEntry]) -> Vec { - let mut names = Vec::new(); - let mut seen = HashSet::new(); - let mut push = |name: String| { - if seen.insert(name.clone()) { - names.push(name); - } - }; - for entry in spec { - match entry { - ScSpecEntry::UdtStructV0(ScSpecUdtStructV0 { name, .. }) - | ScSpecEntry::UdtUnionV0(ScSpecUdtUnionV0 { name, .. }) - | ScSpecEntry::UdtEnumV0(ScSpecUdtEnumV0 { name, .. }) - | ScSpecEntry::UdtErrorEnumV0(ScSpecUdtErrorEnumV0 { name, .. }) => { - push(name.to_utf8_string_lossy()); - } - ScSpecEntry::FunctionV0(_) | ScSpecEntry::EventV0(_) => {} - } - } - names -} - -/// Build the rename map from qualified name to reduced name, grouping by short -/// name so collisions can be disambiguated deterministically. -fn build_mapping(declared: &[String]) -> (BTreeMap, Reduction) { - // Group the declared names by their short name. `BTreeMap`/sorted members - // keep suffix assignment stable across runs. - let mut groups: BTreeMap<&str, Vec<&String>> = BTreeMap::new(); - for name in declared { - groups.entry(short_name(name)).or_default().push(name); - } - for members in groups.values_mut() { - members.sort(); - } - - let mut map = BTreeMap::new(); - let mut renames = Vec::new(); - let mut collisions = Vec::new(); - // Every reduced name handed out, so a numeric suffix never lands on a name - // already taken by another (possibly unrelated) type. - let mut used: HashSet = HashSet::new(); - - for (short, members) in &groups { - let collision = members.len() > 1; - let mut collision_members = Vec::new(); - for (i, full) in members.iter().enumerate() { - let mut n = i; - let mut candidate = if i == 0 { - (*short).to_string() - } else { - format!("{short}{n}") - }; - while used.contains(&candidate) { - n += 1; - candidate = format!("{short}{n}"); - } - used.insert(candidate.clone()); - map.insert((*full).clone(), candidate.clone()); - if **full != candidate { - let rename = Rename { - from: (*full).clone(), - to: candidate.clone(), - }; - renames.push(rename.clone()); - if collision { - collision_members.push(rename); - } - } else if collision { - collision_members.push(Rename { - from: (*full).clone(), - to: candidate, - }); - } - } - if collision { - collisions.push(Collision { - short: (*short).to_string(), - members: collision_members, - }); - } - } - - // Report renames in the spec's declaration order rather than sorted order. - renames.sort_by_key(|r| { - declared - .iter() - .position(|d| *d == r.from) - .unwrap_or(usize::MAX) - }); - - ( - map, - Reduction { - renames, - collisions, - }, - ) -} - -/// Look up the reduced name for a referenced type. A reference to a declared -/// type resolves through the map; a stray qualified name that was never -/// declared still gets its `::` stripped so no invalid identifier leaks into -/// the output. -fn reduced_ref(name: &str, map: &BTreeMap) -> String { - map.get(name) - .cloned() - .unwrap_or_else(|| short_name(name).to_string()) -} - -fn rewrite_entry(entry: &ScSpecEntry, map: &BTreeMap) -> ScSpecEntry { - match entry { - ScSpecEntry::UdtStructV0(s) => { - let mut s = s.clone(); - s.name = rename_udt(&s.name.to_utf8_string_lossy(), map); - s.fields = s - .fields - .iter() - .map(|f| { - let mut f = f.clone(); - f.type_ = rewrite_type(&f.type_, map); - f - }) - .collect::>() - .try_into() - .unwrap_or(s.fields); - ScSpecEntry::UdtStructV0(s) - } - ScSpecEntry::UdtUnionV0(u) => { - let mut u = u.clone(); - u.name = rename_udt(&u.name.to_utf8_string_lossy(), map); - u.cases = u - .cases - .iter() - .map(|c| rewrite_union_case(c, map)) - .collect::>() - .try_into() - .unwrap_or(u.cases); - ScSpecEntry::UdtUnionV0(u) - } - ScSpecEntry::UdtEnumV0(e) => { - let mut e = e.clone(); - e.name = rename_udt(&e.name.to_utf8_string_lossy(), map); - ScSpecEntry::UdtEnumV0(e) - } - ScSpecEntry::UdtErrorEnumV0(e) => { - let mut e = e.clone(); - e.name = rename_udt(&e.name.to_utf8_string_lossy(), map); - ScSpecEntry::UdtErrorEnumV0(e) - } - ScSpecEntry::FunctionV0(f) => { - let ScSpecFunctionV0 { - doc, - name, - inputs, - outputs, - } = f; - let mut f = ScSpecFunctionV0 { - doc: doc.clone(), - name: name.clone(), - inputs: inputs.clone(), - outputs: outputs.clone(), - }; - f.inputs = f - .inputs - .iter() - .map(|i| { - let mut i = i.clone(); - i.type_ = rewrite_type(&i.type_, map); - i - }) - .collect::>() - .try_into() - .unwrap_or(f.inputs); - f.outputs = f - .outputs - .iter() - .map(|o| rewrite_type(o, map)) - .collect::>() - .try_into() - .unwrap_or(f.outputs); - ScSpecEntry::FunctionV0(f) - } - ScSpecEntry::EventV0(e) => { - let ScSpecEventV0 { .. } = e; - let mut e = e.clone(); - e.params = e - .params - .iter() - .map(|p| { - let mut p = p.clone(); - p.type_ = rewrite_type(&p.type_, map); - p - }) - .collect::>() - .try_into() - .unwrap_or(e.params); - ScSpecEntry::EventV0(e) - } - } -} - -fn rewrite_union_case( - case: &ScSpecUdtUnionCaseV0, - map: &BTreeMap, -) -> ScSpecUdtUnionCaseV0 { - match case { - ScSpecUdtUnionCaseV0::VoidV0(_) => case.clone(), - ScSpecUdtUnionCaseV0::TupleV0(t) => { - let mut t = t.clone(); - t.type_ = t - .type_ - .iter() - .map(|ty| rewrite_type(ty, map)) - .collect::>() - .try_into() - .unwrap_or(t.type_); - ScSpecUdtUnionCaseV0::TupleV0(t) - } - } -} - -/// Rewrite a type, recursing into every place a user-defined type can be -/// referenced. -fn rewrite_type(ty: &ScSpecTypeDef, map: &BTreeMap) -> ScSpecTypeDef { - match ty { - ScSpecTypeDef::Udt(ScSpecTypeUdt { name }) => ScSpecTypeDef::Udt(ScSpecTypeUdt { - name: rename_udt(&name.to_utf8_string_lossy(), map), - }), - ScSpecTypeDef::Option(o) => ScSpecTypeDef::Option(Box::new(ScSpecTypeOption { - value_type: Box::new(rewrite_type(&o.value_type, map)), - })), - ScSpecTypeDef::Result(r) => ScSpecTypeDef::Result(Box::new(ScSpecTypeResult { - ok_type: Box::new(rewrite_type(&r.ok_type, map)), - error_type: Box::new(rewrite_type(&r.error_type, map)), - })), - ScSpecTypeDef::Vec(v) => ScSpecTypeDef::Vec(Box::new(ScSpecTypeVec { - element_type: Box::new(rewrite_type(&v.element_type, map)), - })), - ScSpecTypeDef::Map(m) => ScSpecTypeDef::Map(Box::new(ScSpecTypeMap { - key_type: Box::new(rewrite_type(&m.key_type, map)), - value_type: Box::new(rewrite_type(&m.value_type, map)), - })), - ScSpecTypeDef::Tuple(t) => ScSpecTypeDef::Tuple(Box::new(ScSpecTypeTuple { - value_types: t - .value_types - .iter() - .map(|vt| rewrite_type(vt, map)) - .collect::>() - .try_into() - .unwrap_or_else(|_| t.value_types.clone()), - })), - other => other.clone(), - } -} - -/// Rewrite a declaration or reference name to its reduced form as a -/// `StringM<256>`. The reduced name is never longer than the original, so it -/// always fits. -fn rename_udt( - name: &str, - map: &BTreeMap, -) -> stellar_xdr::StringM<{ crate::UDT_NAME_LIMIT }> { - reduced_ref(name, map) - .try_into() - .unwrap_or_else(|_| name.try_into().unwrap_or_default()) -} - -#[cfg(test)] -mod tests { - use super::*; - use stellar_xdr::{ - ScSpecFunctionInputV0, ScSpecTypeUdt, ScSpecUdtEnumV0, ScSpecUdtStructFieldV0, - ScSpecUdtStructV0, StringM, VecM, - }; - - fn udt(name: &str) -> ScSpecTypeDef { - ScSpecTypeDef::Udt(ScSpecTypeUdt { - name: name.try_into().unwrap(), - }) - } - - fn struct_entry(name: &str, field_type: ScSpecTypeDef) -> ScSpecEntry { - ScSpecEntry::UdtStructV0(ScSpecUdtStructV0 { - doc: StringM::default(), - lib: StringM::default(), - name: name.try_into().unwrap(), - fields: vec![ScSpecUdtStructFieldV0 { - doc: StringM::default(), - name: "f".try_into().unwrap(), - type_: field_type, - }] - .try_into() - .unwrap(), - }) - } - - fn enum_entry(name: &str) -> ScSpecEntry { - ScSpecEntry::UdtEnumV0(ScSpecUdtEnumV0 { - doc: StringM::default(), - lib: StringM::default(), - name: name.try_into().unwrap(), - cases: VecM::default(), - }) - } - - fn fn_entry(name: &str, input_type: ScSpecTypeDef) -> ScSpecEntry { - ScSpecEntry::FunctionV0(ScSpecFunctionV0 { - doc: StringM::default(), - name: name.try_into().unwrap(), - inputs: vec![ScSpecFunctionInputV0 { - doc: StringM::default(), - name: "arg".try_into().unwrap(), - type_: input_type, - }] - .try_into() - .unwrap(), - outputs: VecM::default(), - }) - } - - fn entry_name(entry: &ScSpecEntry) -> String { - match entry { - ScSpecEntry::UdtStructV0(s) => s.name.to_utf8_string_lossy(), - ScSpecEntry::UdtUnionV0(u) => u.name.to_utf8_string_lossy(), - ScSpecEntry::UdtEnumV0(e) => e.name.to_utf8_string_lossy(), - ScSpecEntry::UdtErrorEnumV0(e) => e.name.to_utf8_string_lossy(), - _ => String::new(), - } - } - - #[test] - fn no_qualified_names_is_a_noop() { - let spec = vec![enum_entry("State"), struct_entry("Point", udt("State"))]; - let (reduced, report) = reduce_udt_names(&spec); - assert!(report.is_empty()); - assert_eq!(reduced, spec); - } - - #[test] - fn shortens_and_rewrites_references() { - let spec = vec![ - enum_entry("my_contract::inner::State"), - fn_entry("run", udt("my_contract::inner::State")), - ]; - let (reduced, report) = reduce_udt_names(&spec); - - assert_eq!(entry_name(&reduced[0]), "State"); - // The reference inside the function input is rewritten too. - let ScSpecEntry::FunctionV0(f) = &reduced[1] else { - panic!("expected function") - }; - assert_eq!(f.inputs[0].type_, udt("State")); - - assert_eq!(report.renames.len(), 1); - assert_eq!(report.renames[0].from, "my_contract::inner::State"); - assert_eq!(report.renames[0].to, "State"); - assert!(report.collisions.is_empty()); - } - - #[test] - fn disambiguates_collisions_with_suffixes() { - let spec = vec![ - enum_entry("my_contract::a::Status"), - enum_entry("my_contract::b::Status"), - fn_entry("run", udt("my_contract::b::Status")), - ]; - let (reduced, report) = reduce_udt_names(&spec); - - // Deterministic: `a::Status` sorts first and keeps the bare name. - assert_eq!(entry_name(&reduced[0]), "Status"); - assert_eq!(entry_name(&reduced[1]), "Status1"); - // The reference to `b::Status` follows its rename to `Status1`. - let ScSpecEntry::FunctionV0(f) = &reduced[2] else { - panic!("expected function") - }; - assert_eq!(f.inputs[0].type_, udt("Status1")); - - assert_eq!(report.collisions.len(), 1); - let collision = &report.collisions[0]; - assert_eq!(collision.short, "Status"); - assert_eq!(collision.members.len(), 2); - assert_eq!(collision.members[0].to, "Status"); - assert_eq!(collision.members[1].to, "Status1"); - } -} diff --git a/cmd/soroban-cli/src/commands/contract/info/interface.rs b/cmd/soroban-cli/src/commands/contract/info/interface.rs index 7a5f16c1b9..08c90af42b 100644 --- a/cmd/soroban-cli/src/commands/contract/info/interface.rs +++ b/cmd/soroban-cli/src/commands/contract/info/interface.rs @@ -71,27 +71,33 @@ impl Cmd { // below cannot use them as-is. Reduce them to short names for display, // reporting what changed. The `XdrBase64` output is the canonical // on-chain spec, so it is left untouched. - let (reduced_spec, reduction) = soroban_spec_tools::reduce::reduce_udt_names(&spec); + let reduced = soroban_spec::reduce::reduce(&spec); if !matches!(self.output, InfoOutput::XdrBase64) { - for rename in &reduction.renames { + for rename in reduced.renames().filter(|r| r.renamed()) { print.infoln(format!( "Reduced type name {} to {}", - rename.from, rename.to + String::from_utf8_lossy(&rename.from), + String::from_utf8_lossy(&rename.to), )); } - if !reduction.collisions.is_empty() { + let collisions: Vec<_> = reduced.renames().filter(|r| r.collision()).collect(); + if !collisions.is_empty() { use std::fmt::Write as _; let mut msg = String::from( "Reduced type names collided and were disambiguated with a numeric suffix:", ); - for collision in &reduction.collisions { - for member in &collision.members { - let _ = write!(msg, "\n {} -> {}", member.from, member.to); - } + for rename in collisions { + let _ = write!( + msg, + "\n {} -> {}", + String::from_utf8_lossy(&rename.from), + String::from_utf8_lossy(&rename.to), + ); } print.warnln(msg); } } + let reduced_spec: Vec<_> = reduced.into_entries().collect(); let res = match self.output { InfoOutput::XdrBase64 => base64, From be26582872323a23ed111c29ee3d2568063d7592 Mon Sep 17 00:00:00 2001 From: Leigh <351529+leighmcculloch@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:29:44 +0000 Subject: [PATCH 3/6] reduce spec type names on contract build --- .../src/commands/contract/build.rs | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/cmd/soroban-cli/src/commands/contract/build.rs b/cmd/soroban-cli/src/commands/contract/build.rs index e45e6def1d..3b0535e7bc 100644 --- a/cmd/soroban-cli/src/commands/contract/build.rs +++ b/cmd/soroban-cli/src/commands/contract/build.rs @@ -400,6 +400,7 @@ impl Cmd { self.inject_meta(&target_file_path)?; Self::filter_spec(&target_file_path)?; + Self::reduce_spec(&print, &p.name, &target_file_path)?; let final_path = if let Some(out_dir) = &self.out_dir { fs::create_dir_all(out_dir).map_err(Error::CreatingOutDir)?; @@ -569,6 +570,65 @@ impl Cmd { fs::write(target_file_path, new_wasm).map_err(Error::WritingWasmFile) } + /// Reduces user-defined type names in the contract spec to their simple + /// form, rewriting the `contractspecv0` section in place. + /// + /// Contract specs may name user-defined types by their fully qualified path + /// (e.g. `my_contract::inner::State`). This follows each to its simple name + /// (e.g. `State`), rewriting every reference, so the on-chain spec and every + /// downstream tool see the short names. Names that would collide are + /// disambiguated with a numeric suffix, which is warned about. + /// + /// Runs after `filter_spec` so only the entries that survive shaking are + /// reduced, but is otherwise independent of spec shaking. + fn reduce_spec(print: &Print, name: &str, target_file_path: &PathBuf) -> Result<(), Error> { + use soroban_spec_tools::contract::Spec; + use soroban_spec_tools::wasm::replace_custom_section; + + let wasm_bytes = fs::read(target_file_path).map_err(Error::ReadingWasmFile)?; + let spec = Spec::new(&wasm_bytes)?; + + let reduced = soroban_spec::reduce::reduce(&spec.spec); + + // If every name was already simple, leave the wasm untouched. + if reduced.renames().all(|r| !r.renamed()) { + return Ok(()); + } + + let collisions: Vec<_> = reduced.renames().filter(|r| r.collision()).collect(); + if !collisions.is_empty() { + use std::fmt::Write as _; + let mut msg = format!( + "{name}: reduced type names collided and were disambiguated with a numeric suffix:" + ); + for rename in collisions { + let _ = write!( + msg, + "\n {} -> {}", + String::from_utf8_lossy(&rename.from), + String::from_utf8_lossy(&rename.to), + ); + } + print.warnln(msg); + } + + // Encode the reduced entries and replace the contractspecv0 section. + let mut reduced_xdr = Vec::new(); + let mut writer = Limited::new( + Cursor::new(&mut reduced_xdr), + Limits::depth(XDR_DEPTH_LIMIT), + ); + for entry in reduced.into_entries() { + entry.write_xdr(&mut writer)?; + } + + let new_wasm = replace_custom_section(&wasm_bytes, "contractspecv0", &reduced_xdr) + .map_err(|e| Error::WasmParsing(e.to_string()))?; + + fs::remove_file(target_file_path).map_err(Error::DeletingArtifact)?; + fs::write(target_file_path, new_wasm).map_err(Error::WritingWasmFile) + } + fn encoded_new_meta(&self) -> Result, Error> { let mut new_meta: Vec = Vec::new(); @@ -970,6 +1030,46 @@ mod tests { assert!(Cmd::try_parse_from(["build", "--pull"]).is_err()); } + #[test] + fn reduce_spec_shortens_qualified_names_in_wasm() { + use soroban_spec_tools::contract::Spec; + use soroban_spec_tools::wasm::replace_custom_section; + use stellar_xdr::{ScSpecEntry, ScSpecUdtStructV0, VecM}; + + // A spec with a single struct whose name is fully qualified. + let entry = ScSpecEntry::UdtStructV0(ScSpecUdtStructV0 { + doc: StringM::default(), + lib: StringM::default(), + name: "mycrate::mymod::MyType".to_string().try_into().unwrap(), + fields: VecM::default(), + }); + let mut spec_xdr = Vec::new(); + entry + .write_xdr(&mut Limited::new( + Cursor::new(&mut spec_xdr), + Limits::depth(XDR_DEPTH_LIMIT), + )) + .unwrap(); + + // Embed the spec in a minimal (empty) wasm module and write it out. + let wasm = replace_custom_section(b"\0asm\x01\0\0\0", "contractspecv0", &spec_xdr).unwrap(); + let path = + env::temp_dir().join(format!("reduce_spec_test_{}.wasm", std::process::id())); + fs::write(&path, &wasm).unwrap(); + + Cmd::reduce_spec(&Print::new(true), "pkg", &path).unwrap(); + + let out = fs::read(&path).unwrap(); + fs::remove_file(&path).ok(); + let spec = Spec::new(&out).unwrap(); + + assert_eq!(spec.spec.len(), 1); + let ScSpecEntry::UdtStructV0(s) = &spec.spec[0] else { + panic!("expected a struct entry, got {:?}", spec.spec[0]); + }; + assert_eq!(s.name.to_vec(), b"MyType".to_vec()); + } + #[test] fn serialize_command_shell_escapes_args_with_metacharacters() { let raw_arg = "--manifest-path=/path/to/contract;touch PWNED;#/Cargo.toml"; From 3c430e3d88f524994b979e0ca8075e77aa316ad3 Mon Sep 17 00:00:00 2001 From: Leigh <351529+leighmcculloch@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:43:53 +0000 Subject: [PATCH 4/6] show raw spec type names in contract info --- .../src/commands/contract/info/interface.rs | 42 +++---------------- 1 file changed, 5 insertions(+), 37 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/info/interface.rs b/cmd/soroban-cli/src/commands/contract/info/interface.rs index 08c90af42b..2ab2aaf1c7 100644 --- a/cmd/soroban-cli/src/commands/contract/info/interface.rs +++ b/cmd/soroban-cli/src/commands/contract/info/interface.rs @@ -65,51 +65,19 @@ impl Cmd { } }; - // Contract specs may name user-defined types by their fully qualified - // path (e.g. `my_contract::inner::State`). Those names are noisy and, - // because `::` is not a valid identifier, the Rust and JSON renderers - // below cannot use them as-is. Reduce them to short names for display, - // reporting what changed. The `XdrBase64` output is the canonical - // on-chain spec, so it is left untouched. - let reduced = soroban_spec::reduce::reduce(&spec); - if !matches!(self.output, InfoOutput::XdrBase64) { - for rename in reduced.renames().filter(|r| r.renamed()) { - print.infoln(format!( - "Reduced type name {} to {}", - String::from_utf8_lossy(&rename.from), - String::from_utf8_lossy(&rename.to), - )); - } - let collisions: Vec<_> = reduced.renames().filter(|r| r.collision()).collect(); - if !collisions.is_empty() { - use std::fmt::Write as _; - let mut msg = String::from( - "Reduced type names collided and were disambiguated with a numeric suffix:", - ); - for rename in collisions { - let _ = write!( - msg, - "\n {} -> {}", - String::from_utf8_lossy(&rename.from), - String::from_utf8_lossy(&rename.to), - ); - } - print.warnln(msg); - } - } - let reduced_spec: Vec<_> = reduced.into_entries().collect(); - + // Type names in the spec are already reduced to simple names during + // `contract build`, so show the spec as it is stored in the contract. let res = match self.output { InfoOutput::XdrBase64 => base64, - InfoOutput::Json => serde_json::to_string(&reduced_spec)?, - InfoOutput::JsonFormatted => serde_json::to_string_pretty(&reduced_spec)?, + InfoOutput::Json => serde_json::to_string(&spec)?, + InfoOutput::JsonFormatted => serde_json::to_string_pretty(&spec)?, // soroban_spec_rust drops doc strings entirely (rustdocs can execute // code) and routes every spec name through `format_ident!`, which // rejects non-identifier bytes. If a future revision starts // emitting spec strings as `Literal::string` or rustdocs, this // path becomes a terminal-escape-injection vector and must be // sanitized before printing. - InfoOutput::Rust => soroban_spec_rust::generate_without_file(&reduced_spec)? + InfoOutput::Rust => soroban_spec_rust::generate_without_file(&spec)? .to_formatted_string() .expect("Unexpected spec format error"), }; From 301e40efcbd8fbb46d42ad45b1f6dfd132632d65 Mon Sep 17 00:00:00 2001 From: Leigh <351529+leighmcculloch@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:38:58 +1000 Subject: [PATCH 5/6] remove comment --- cmd/soroban-cli/src/commands/contract/info/interface.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/info/interface.rs b/cmd/soroban-cli/src/commands/contract/info/interface.rs index 2ab2aaf1c7..a2dfa1de38 100644 --- a/cmd/soroban-cli/src/commands/contract/info/interface.rs +++ b/cmd/soroban-cli/src/commands/contract/info/interface.rs @@ -65,8 +65,6 @@ impl Cmd { } }; - // Type names in the spec are already reduced to simple names during - // `contract build`, so show the spec as it is stored in the contract. let res = match self.output { InfoOutput::XdrBase64 => base64, InfoOutput::Json => serde_json::to_string(&spec)?, From 85ed88eb408eb78956e6188e479bb846ebcb944e Mon Sep 17 00:00:00 2001 From: Leigh <351529+leighmcculloch@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:17:17 +0000 Subject: [PATCH 6/6] format the reduce spec test path --- cmd/soroban-cli/src/commands/contract/build.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/build.rs b/cmd/soroban-cli/src/commands/contract/build.rs index 3b0535e7bc..0ab745649a 100644 --- a/cmd/soroban-cli/src/commands/contract/build.rs +++ b/cmd/soroban-cli/src/commands/contract/build.rs @@ -1053,8 +1053,7 @@ mod tests { // Embed the spec in a minimal (empty) wasm module and write it out. let wasm = replace_custom_section(b"\0asm\x01\0\0\0", "contractspecv0", &spec_xdr).unwrap(); - let path = - env::temp_dir().join(format!("reduce_spec_test_{}.wasm", std::process::id())); + let path = env::temp_dir().join(format!("reduce_spec_test_{}.wasm", std::process::id())); fs::write(&path, &wasm).unwrap(); Cmd::reduce_spec(&Print::new(true), "pkg", &path).unwrap();