Skip to main content

sdkt_wasm/
spec.rs

1//! ContractSpec ABI parser for Soroban contracts.
2//!
3//! Reads the `contractspecv0` (and `contractenvmetav0`) custom sections from
4//! compiled Soroban WASM and exposes a typed, serializable `ContractSpec`.
5//!
6//! The `contractspecv0` payload is a sequence of XDR-encoded [`ScSpecEntry`]
7//! tuples. Functions, user-defined structs/unions/enums, and events are all
8//! discriminated by [`ScSpecEntryKind`].
9
10use serde::{Deserialize, Serialize};
11use std::io::Cursor;
12use stellar_xdr::{
13    Limited, Limits, ReadXdr, ScMetaEntry, ScSpecEntry, ScSpecEntryKind, ScSpecTypeDef,
14};
15use wasmparser::Payload;
16
17use crate::{ContractMetaEntry, WasmError};
18
19/// Names of the Soroban custom sections this parser understands.
20pub const CONTRACT_SPEC_V0: &str = "contractspecv0";
21/// Environment metadata section (contract spec version marker).
22pub const CONTRACT_ENV_META_V0: &str = "contractenvmetav0";
23/// Contract metadata section (arbitrary key/value `SCMetaEntry` pairs, SEP-46).
24pub const CONTRACT_META_V0: &str = "contractmetav0";
25
26/// The full contract ABI, as declared in the compiled WASM.
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28pub struct ContractSpec {
29    /// Contract-level metadata (from `contractenvmetav0`, currently the
30    /// interface version integer).
31    pub env_meta: Option<EnvMetaSpec>,
32    /// All declared functions.
33    pub functions: Vec<ContractFunction>,
34    /// All user-defined types (structs, unions, enums, error enums).
35    pub custom_types: Vec<ContractType>,
36    /// Declared events.
37    pub events: Vec<ContractEvent>,
38}
39
40/// Parsed `contractenvmetav0` payload.
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42pub struct EnvMetaSpec {
43    /// Interface version reported by the contract.
44    pub interface_version: u64,
45}
46
47/// A single exported contract function.
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct ContractFunction {
50    /// Function name (the scSymbol).
51    pub name: String,
52    /// Doc comment attached to the function, if any.
53    pub doc: String,
54    /// Ordered input parameters.
55    pub parameters: Vec<ContractParameter>,
56    /// Ordered output types.
57    pub outputs: Vec<ContractType>,
58}
59
60/// A named input parameter of a contract function.
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62pub struct ContractParameter {
63    /// Parameter name.
64    pub name: String,
65    /// Doc comment, if any.
66    pub doc: String,
67    /// Type, expressed as a [`ContractType`].
68    pub type_: ContractType,
69}
70
71/// A user-defined type declaration (struct / union / enum / error enum).
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73pub struct ContractType {
74    /// Type name.
75    pub name: String,
76    /// XDR kind (Struct / Union / Enum / ErrorEnum).
77    pub kind: String,
78    /// Doc comment, if any.
79    pub doc: String,
80    /// Members (fields for structs, variants for enums/unions).
81    pub members: Vec<TypeMember>,
82    /// Type arguments for compound types such as `Vec<T>`, `Map<K, V>`, and
83    /// `Result<T, E>`.
84    #[serde(default)]
85    pub type_args: Vec<ContractType>,
86    /// Length for the fixed-size `BytesN` type.
87    #[serde(default)]
88    pub bytes_n: Option<u32>,
89}
90
91/// A member of a user-defined type.
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93pub struct TypeMember {
94    /// Member name.
95    pub name: String,
96    /// Doc comment, if any.
97    pub doc: String,
98    /// The member's type(s). A struct field has exactly one; a union tuple case
99    /// carries one per tuple element; void union cases and enum cases have
100    /// none. Retained so a type whose members keep their names but change their
101    /// types is still detectable as a definition change.
102    pub types: Vec<ContractType>,
103    /// The case's discriminant, for enum and error-enum members only. This is
104    /// the numeric value an `ScVal` carries for the case, so a remapped
105    /// discriminant silently changes how existing data decodes.
106    pub value: Option<u32>,
107}
108
109/// A parameter declared by a Soroban event.
110pub type ContractEventParameter = EventParam;
111
112/// A declared Soroban event.
113///
114/// The full XDR signature (`prefix_topics`, `params`, `data_format`) is
115/// retained so that an event which keeps its name but changes its shape is
116/// still comparable — see [`crate::spec_diff`].
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118pub struct ContractEvent {
119    /// Event name.
120    pub name: String,
121    /// Doc comment, if any.
122    pub doc: String,
123    /// Topic symbols emitted before the declared params.
124    pub prefix_topics: Vec<String>,
125    /// Ordered event parameters, each carrying its topic-list-vs-data location.
126    pub params: Vec<EventParam>,
127    /// XDR data format (`single_value` / `vec` / `map`).
128    pub data_format: String,
129}
130
131/// A single parameter of a declared event (`ScSpecEventParamV0`).
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
133pub struct EventParam {
134    /// Parameter name.
135    pub name: String,
136    /// Doc comment, if any.
137    pub doc: String,
138    /// Type, expressed as a [`ContractType`].
139    pub type_: ContractType,
140    /// Where the value is carried: `data` or `topic_list`.
141    pub location: String,
142}
143
144/// Parses the Soroban contract spec from compiled WASM bytes.
145///
146/// This reuses the same [`wasmparser::Parser`] walk as [`crate::parse_metadata`]
147/// but extracts and decodes the `contractspecv0` / `contractenvmetav0` custom
148/// section payloads.
149///
150/// # Errors
151///
152/// Returns [`WasmError::Empty`] for empty input, [`WasmError::Parse`] for
153/// malformed WASM or a malformed spec section, and
154/// [`WasmError::NoContractSpec`] when the WASM is valid but declares no
155/// contract spec (i.e. it is not a Soroban contract).
156pub fn parse_contract_spec(raw_wasm: &[u8]) -> Result<ContractSpec, WasmError> {
157    if raw_wasm.is_empty() {
158        return Err(WasmError::Empty);
159    }
160
161    let parser = wasmparser::Parser::new(0);
162    let mut functions = Vec::new();
163    let mut custom_types = Vec::new();
164    let mut events = Vec::new();
165    let mut env_meta: Option<EnvMetaSpec> = None;
166    let mut saw_spec = false;
167
168    for payload in parser.parse_all(raw_wasm) {
169        if let Payload::CustomSection(reader) = payload? {
170            match reader.name() {
171                CONTRACT_SPEC_V0 => {
172                    saw_spec = true;
173                    decode_spec_section(
174                        reader.data(),
175                        &mut functions,
176                        &mut custom_types,
177                        &mut events,
178                    )?;
179                }
180                CONTRACT_ENV_META_V0 => {
181                    env_meta = Some(decode_env_meta_section(reader.data())?);
182                }
183                _ => {}
184            }
185        }
186    }
187
188    if !saw_spec {
189        return Err(WasmError::NoContractSpec);
190    }
191
192    Ok(ContractSpec {
193        env_meta,
194        functions,
195        custom_types,
196        events,
197    })
198}
199
200/// Decodes a sequence of XDR `ScSpecEntry` values into the typed model.
201fn decode_spec_section(
202    data: &[u8],
203    functions: &mut Vec<ContractFunction>,
204    custom_types: &mut Vec<ContractType>,
205    events: &mut Vec<ContractEvent>,
206) -> Result<(), WasmError> {
207    let mut items = data;
208    // A spec section may be either a single ScSpecEntry or a set of
209    // concatenated entries. `ScSpecEntry::read_xdr` consumes one entry at a
210    // time, so we loop over the buffer until no bytes remain (or an XDR error).
211    while !items.is_empty() {
212        let mut cursor = Cursor::new(items);
213        let mut limited = Limited::new(&mut cursor, Limits::none());
214        let entry = ScSpecEntry::read_xdr(&mut limited).map_err(WasmError::SpecXdr)?;
215
216        match entry {
217            ScSpecEntry::FunctionV0(f) => {
218                functions.push(ContractFunction {
219                    name: f.name.to_utf8_string_lossy(),
220                    doc: f.doc.to_utf8_string_lossy(),
221                    parameters: f
222                        .inputs
223                        .iter()
224                        .map(|i| ContractParameter {
225                            name: i.name.to_utf8_string_lossy(),
226                            doc: i.doc.to_utf8_string_lossy(),
227                            type_: map_type_def(&i.type_),
228                        })
229                        .collect(),
230                    outputs: f.outputs.iter().map(map_type_def).collect(),
231                });
232            }
233            ScSpecEntry::UdtStructV0(s) => custom_types.push(map_udt_struct(s)),
234            ScSpecEntry::UdtUnionV0(u) => custom_types.push(map_udt_union(u)),
235            ScSpecEntry::UdtEnumV0(e) => custom_types.push(map_udt_enum(e)),
236            ScSpecEntry::UdtErrorEnumV0(e) => custom_types.push(map_udt_error_enum(e)),
237            ScSpecEntry::EventV0(e) => events.push(map_event(e)),
238        }
239
240        let consumed = cursor.position() as usize;
241        items = &items[consumed..];
242    }
243    Ok(())
244}
245
246/// Decodes the `contractenvmetav0` payload (a single `u64` interface version).
247fn decode_env_meta_section(data: &[u8]) -> Result<EnvMetaSpec, WasmError> {
248    if data.len() < 8 {
249        return Err(WasmError::SpecXdr(stellar_xdr::Error::Invalid));
250    }
251    let raw = [
252        data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7],
253    ];
254    // ReadXdr for Uint64 is big-endian like Stellar XDR.
255    let mut cursor = Cursor::new(&raw[..]);
256    let mut limited = Limited::new(&mut cursor, Limits::none());
257    let v = stellar_xdr::Uint64::read_xdr(&mut limited).map_err(WasmError::SpecXdr)?;
258    Ok(EnvMetaSpec {
259        interface_version: v,
260    })
261}
262
263/// Decodes the `contractmetav0` payload into ordered key/value entries.
264///
265/// The section is a sequence of concatenated XDR `SCMetaEntry` values (each a
266/// `SCMetaV0 { string key; string val; }` union arm). The key/value vocabulary
267/// is open — SEP-58 continues to evolve it (`rsver`, `rssdkver`, `cliver`,
268/// `bldimg`, `source_repo`/`source_rev`, and newer names) — so this decoder
269/// performs no vocabulary matching and preserves duplicates and order.
270///
271/// A present-but-empty section yields an empty list. A payload that does not
272/// decode cleanly as XDR is surfaced as [`WasmError::MetaXdr`] rather than
273/// silently dropped, so a corrupted section cannot masquerade as "no metadata".
274pub fn decode_contract_meta(data: &[u8]) -> Result<Vec<ContractMetaEntry>, WasmError> {
275    let mut entries = Vec::new();
276    let mut items = data;
277    while !items.is_empty() {
278        let mut cursor = Cursor::new(items);
279        let mut limited = Limited::new(&mut cursor, Limits::none());
280        let entry = ScMetaEntry::read_xdr(&mut limited).map_err(WasmError::MetaXdr)?;
281
282        match entry {
283            ScMetaEntry::ScMetaV0(v0) => entries.push(ContractMetaEntry {
284                key: v0.key.to_utf8_string_lossy(),
285                value: v0.val.to_utf8_string_lossy(),
286            }),
287        }
288
289        let consumed = cursor.position() as usize;
290        items = &items[consumed..];
291    }
292    Ok(entries)
293}
294
295fn map_type_def(t: &ScSpecTypeDef) -> ContractType {
296    fn primitive(name: &str) -> ContractType {
297        ContractType {
298            name: name.to_string(),
299            kind: "primitive".to_string(),
300            doc: String::new(),
301            members: vec![],
302            type_args: vec![],
303            bytes_n: None,
304        }
305    }
306
307    fn compound(name: String, type_args: Vec<ContractType>, labels: &[&str]) -> ContractType {
308        let members = type_args
309            .iter()
310            .zip(labels)
311            .map(|(ty, label)| TypeMember {
312                name: (*label).to_string(),
313                doc: String::new(),
314                types: vec![ty.clone()],
315                value: None,
316            })
317            .collect();
318        ContractType {
319            name,
320            kind: "compound".to_string(),
321            doc: String::new(),
322            members,
323            type_args,
324            bytes_n: None,
325        }
326    }
327
328    match t {
329        ScSpecTypeDef::Val => primitive("val"),
330        ScSpecTypeDef::Bool => primitive("bool"),
331        ScSpecTypeDef::Void => primitive("void"),
332        ScSpecTypeDef::Error => primitive("error"),
333        ScSpecTypeDef::U32 => primitive("u32"),
334        ScSpecTypeDef::I32 => primitive("i32"),
335        ScSpecTypeDef::U64 => primitive("u64"),
336        ScSpecTypeDef::I64 => primitive("i64"),
337        ScSpecTypeDef::Timepoint => primitive("timepoint"),
338        ScSpecTypeDef::Duration => primitive("duration"),
339        ScSpecTypeDef::U128 => primitive("u128"),
340        ScSpecTypeDef::I128 => primitive("i128"),
341        ScSpecTypeDef::U256 => primitive("u256"),
342        ScSpecTypeDef::I256 => primitive("i256"),
343        ScSpecTypeDef::Bytes => primitive("bytes"),
344        ScSpecTypeDef::String => primitive("string"),
345        ScSpecTypeDef::Symbol => primitive("symbol"),
346        ScSpecTypeDef::Address => primitive("address"),
347        ScSpecTypeDef::MuxedAddress => primitive("muxed_address"),
348        ScSpecTypeDef::Option(inner) => {
349            let args = vec![map_type_def(&inner.value_type)];
350            compound(format!("option<{}>", args[0].name), args, &["value_type"])
351        }
352        ScSpecTypeDef::Result(inner) => {
353            let args = vec![
354                map_type_def(&inner.ok_type),
355                map_type_def(&inner.error_type),
356            ];
357            let name = format!("result<{}, {}>", args[0].name, args[1].name);
358            compound(name, args, &["ok_type", "error_type"])
359        }
360        ScSpecTypeDef::Vec(inner) => {
361            let args = vec![map_type_def(&inner.element_type)];
362            compound(format!("vec<{}>", args[0].name), args, &["element_type"])
363        }
364        ScSpecTypeDef::Map(inner) => {
365            let args = vec![
366                map_type_def(&inner.key_type),
367                map_type_def(&inner.value_type),
368            ];
369            let name = format!("map<{}, {}>", args[0].name, args[1].name);
370            compound(name, args, &["key_type", "value_type"])
371        }
372        ScSpecTypeDef::Tuple(inner) => {
373            let args: Vec<_> = inner.value_types.iter().map(map_type_def).collect();
374            let name = format!(
375                "tuple<{}>",
376                args.iter()
377                    .map(|ty| ty.name.as_str())
378                    .collect::<Vec<_>>()
379                    .join(", ")
380            );
381            let labels: Vec<_> = (0..args.len()).map(|i| i.to_string()).collect();
382            let members = args
383                .iter()
384                .zip(labels.iter())
385                .map(|(ty, label)| TypeMember {
386                    name: label.clone(),
387                    doc: String::new(),
388                    types: vec![ty.clone()],
389                    value: None,
390                })
391                .collect();
392            ContractType {
393                name,
394                kind: "compound".to_string(),
395                doc: String::new(),
396                members,
397                type_args: args,
398                bytes_n: None,
399            }
400        }
401        ScSpecTypeDef::BytesN(inner) => ContractType {
402            name: format!("bytesn<{}>", inner.n),
403            kind: "compound".to_string(),
404            doc: String::new(),
405            members: vec![],
406            type_args: vec![],
407            bytes_n: Some(inner.n),
408        },
409        ScSpecTypeDef::Udt(user_type) => ContractType {
410            name: user_type.name.to_utf8_string_lossy(),
411            kind: "udt".to_string(),
412            doc: String::new(),
413            members: vec![],
414            type_args: vec![],
415            bytes_n: None,
416        },
417    }
418}
419
420fn map_udt_struct(s: stellar_xdr::ScSpecUdtStructV0) -> ContractType {
421    ContractType {
422        name: s.name.to_utf8_string_lossy(),
423        kind: "struct".to_string(),
424        doc: s.doc.to_utf8_string_lossy(),
425        members: s
426            .fields
427            .iter()
428            .map(|f| TypeMember {
429                name: f.name.to_utf8_string_lossy(),
430                doc: f.doc.to_utf8_string_lossy(),
431                types: vec![map_type_def(&f.type_)],
432                value: None,
433            })
434            .collect(),
435        type_args: vec![],
436        bytes_n: None,
437    }
438}
439
440fn map_udt_union(u: stellar_xdr::ScSpecUdtUnionV0) -> ContractType {
441    ContractType {
442        name: u.name.to_utf8_string_lossy(),
443        kind: "union".to_string(),
444        doc: u.doc.to_utf8_string_lossy(),
445        members: u
446            .cases
447            .iter()
448            .map(|c| match c {
449                stellar_xdr::ScSpecUdtUnionCaseV0::VoidV0(v) => TypeMember {
450                    name: v.name.to_utf8_string_lossy(),
451                    doc: v.doc.to_utf8_string_lossy(),
452                    types: vec![],
453                    value: None,
454                },
455                stellar_xdr::ScSpecUdtUnionCaseV0::TupleV0(t) => TypeMember {
456                    name: t.name.to_utf8_string_lossy(),
457                    doc: t.doc.to_utf8_string_lossy(),
458                    types: t.type_.iter().map(map_type_def).collect(),
459                    value: None,
460                },
461            })
462            .collect(),
463        type_args: vec![],
464        bytes_n: None,
465    }
466}
467
468fn map_udt_enum(e: stellar_xdr::ScSpecUdtEnumV0) -> ContractType {
469    ContractType {
470        name: e.name.to_utf8_string_lossy(),
471        kind: "enum".to_string(),
472        doc: e.doc.to_utf8_string_lossy(),
473        members: e
474            .cases
475            .iter()
476            .map(|c| TypeMember {
477                name: c.name.to_utf8_string_lossy(),
478                doc: c.doc.to_utf8_string_lossy(),
479                types: vec![],
480                value: Some(c.value),
481            })
482            .collect(),
483        type_args: vec![],
484        bytes_n: None,
485    }
486}
487
488fn map_udt_error_enum(e: stellar_xdr::ScSpecUdtErrorEnumV0) -> ContractType {
489    ContractType {
490        name: e.name.to_utf8_string_lossy(),
491        kind: "error_enum".to_string(),
492        doc: e.doc.to_utf8_string_lossy(),
493        members: e
494            .cases
495            .iter()
496            .map(|c| TypeMember {
497                name: c.name.to_utf8_string_lossy(),
498                doc: c.doc.to_utf8_string_lossy(),
499                types: vec![],
500                value: Some(c.value),
501            })
502            .collect(),
503        type_args: vec![],
504        bytes_n: None,
505    }
506}
507
508/// Maps an `EventV0` entry, retaining the full event signature (prefix
509/// topics, params, and data format) rather than just the name.
510fn map_event(e: stellar_xdr::ScSpecEventV0) -> ContractEvent {
511    ContractEvent {
512        name: e.name.to_utf8_string_lossy(),
513        doc: e.doc.to_utf8_string_lossy(),
514        prefix_topics: e
515            .prefix_topics
516            .iter()
517            .map(|t| t.to_utf8_string_lossy())
518            .collect(),
519        params: e
520            .params
521            .iter()
522            .map(|p| EventParam {
523                name: p.name.to_utf8_string_lossy(),
524                doc: p.doc.to_utf8_string_lossy(),
525                type_: map_type_def(&p.type_),
526                location: match p.location {
527                    stellar_xdr::ScSpecEventParamLocationV0::Data => "data".to_string(),
528                    stellar_xdr::ScSpecEventParamLocationV0::TopicList => "topic_list".to_string(),
529                },
530            })
531            .collect(),
532        data_format: match e.data_format {
533            stellar_xdr::ScSpecEventDataFormat::SingleValue => "single_value".to_string(),
534            stellar_xdr::ScSpecEventDataFormat::Vec => "vec".to_string(),
535            stellar_xdr::ScSpecEventDataFormat::Map => "map".to_string(),
536        },
537    }
538}
539
540// Keep `ScSpecEntryKind` referenced to avoid unused-import warnings on some
541// feature combinations.
542#[allow(dead_code)]
543fn _discriminant_name(kind: &ScSpecEntryKind) -> &'static str {
544    match kind {
545        ScSpecEntryKind::FunctionV0 => "function_v0",
546        ScSpecEntryKind::UdtStructV0 => "udt_struct_v0",
547        ScSpecEntryKind::UdtUnionV0 => "udt_union_v0",
548        ScSpecEntryKind::UdtEnumV0 => "udt_enum_v0",
549        ScSpecEntryKind::UdtErrorEnumV0 => "udt_error_enum_v0",
550        ScSpecEntryKind::EventV0 => "event_v0",
551    }
552}
553
554#[cfg(test)]
555pub(crate) mod tests {
556    use super::*;
557    use stellar_xdr::WriteXdr;
558
559    /// Minimal valid WASM (magic + version 1).
560    const VALID_WASM: &[u8] = &[0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
561
562    /// Encodes `ScSpecEntry` values into a `contractspecv0` custom section.
563    pub(crate) fn spec_section(entries: &[ScSpecEntry]) -> Vec<u8> {
564        // Build the custom section payload: name + encoded XDR entries.
565        let mut section = Vec::new();
566        section.push(CONTRACT_SPEC_V0.len() as u8);
567        section.extend_from_slice(CONTRACT_SPEC_V0.as_bytes());
568        for e in entries {
569            let mut buf = Vec::new();
570            let mut cursor = Cursor::new(&mut buf);
571            let mut l = Limited::new(&mut cursor, Limits::none());
572            e.write_xdr(&mut l).unwrap();
573            section.extend_from_slice(&buf);
574        }
575        // Assemble: WASM magic + version, custom-section id(0), uleb128 size, payload.
576        let mut result = vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
577        result.push(0); // section id
578        let mut sz = section.len() as u32;
579        let mut size_bytes = Vec::new();
580        while sz >= 0x80 {
581            size_bytes.push((sz as u8 & 0x7f) | 0x80);
582            sz >>= 7;
583        }
584        size_bytes.push(sz as u8);
585        result.extend_from_slice(&size_bytes);
586        result.extend_from_slice(&section);
587        result
588    }
589
590    pub(crate) fn symbol_e(s: &str) -> stellar_xdr::ScSymbol {
591        stellar_xdr::ScSymbol(s.to_string().try_into().unwrap())
592    }
593
594    pub(crate) fn func_entry(name: &str, inputs: Vec<(String, ScSpecTypeDef)>) -> ScSpecEntry {
595        use stellar_xdr::{ScSpecFunctionInputV0, ScSpecFunctionV0};
596        ScSpecEntry::FunctionV0(ScSpecFunctionV0 {
597            doc: "".try_into().unwrap(),
598            name: symbol_e(name),
599            inputs: inputs
600                .into_iter()
601                .map(|(n, t)| ScSpecFunctionInputV0 {
602                    doc: "".try_into().unwrap(),
603                    name: n.try_into().unwrap(),
604                    type_: t,
605                })
606                .collect::<Vec<_>>()
607                .try_into()
608                .unwrap(),
609            outputs: vec![].try_into().unwrap(),
610        })
611    }
612
613    pub(crate) fn event_entry(name: &str) -> ScSpecEntry {
614        event_entry_with_params(name, vec![])
615    }
616
617    pub(crate) fn event_entry_with_params(
618        name: &str,
619        params: Vec<(String, ScSpecTypeDef)>,
620    ) -> ScSpecEntry {
621        use stellar_xdr::{ScSpecEventParamLocationV0, ScSpecEventParamV0, ScSpecEventV0};
622        ScSpecEntry::EventV0(ScSpecEventV0 {
623            doc: "".try_into().unwrap(),
624            lib: "soroban_sdk".try_into().unwrap(),
625            name: symbol_e(name),
626            prefix_topics: vec![].try_into().unwrap(),
627            params: params
628                .into_iter()
629                .map(|(n, t)| ScSpecEventParamV0 {
630                    doc: "".try_into().unwrap(),
631                    name: n.try_into().unwrap(),
632                    type_: t,
633                    location: ScSpecEventParamLocationV0::Data,
634                })
635                .collect::<Vec<_>>()
636                .try_into()
637                .unwrap(),
638            data_format: stellar_xdr::ScSpecEventDataFormat::SingleValue,
639        })
640    }
641
642    pub(crate) fn udt_struct_entry(name: &str) -> ScSpecEntry {
643        udt_struct_entry_with_fields(name, vec![])
644    }
645
646    pub(crate) fn udt_struct_entry_with_fields(
647        name: &str,
648        fields: Vec<(String, ScSpecTypeDef)>,
649    ) -> ScSpecEntry {
650        use stellar_xdr::{ScSpecUdtStructFieldV0, ScSpecUdtStructV0};
651        ScSpecEntry::UdtStructV0(ScSpecUdtStructV0 {
652            doc: "".try_into().unwrap(),
653            lib: "soroban_sdk".try_into().unwrap(),
654            name: name.try_into().unwrap(),
655            fields: fields
656                .into_iter()
657                .map(|(n, t)| ScSpecUdtStructFieldV0 {
658                    doc: "".try_into().unwrap(),
659                    name: n.try_into().unwrap(),
660                    type_: t,
661                })
662                .collect::<Vec<_>>()
663                .try_into()
664                .unwrap(),
665        })
666    }
667
668    #[test]
669    fn empty_wasm() {
670        assert!(matches!(parse_contract_spec(&[]), Err(WasmError::Empty)));
671    }
672
673    #[test]
674    fn invalid_wasm() {
675        let res = parse_contract_spec(b"not wasm at all");
676        assert!(matches!(res, Err(WasmError::Parse(_))));
677    }
678
679    #[test]
680    fn no_contract_spec() {
681        // Valid WASM without a `contractspecv0` section.
682        let res = parse_contract_spec(VALID_WASM);
683        assert!(matches!(res, Err(WasmError::NoContractSpec)));
684    }
685
686    #[test]
687    fn valid_single_function() {
688        let wasm = spec_section(&[func_entry(
689            "greet",
690            vec![("name".to_string(), ScSpecTypeDef::String)],
691        )]);
692        let spec = parse_contract_spec(&wasm).unwrap();
693        assert_eq!(spec.functions.len(), 1);
694        assert_eq!(spec.functions[0].name, "greet");
695        assert_eq!(spec.functions[0].parameters.len(), 1);
696        assert_eq!(spec.functions[0].parameters[0].name, "name");
697        assert_eq!(spec.functions[0].parameters[0].type_.name, "string");
698    }
699
700    #[test]
701    fn multiple_functions() {
702        let wasm = spec_section(&[
703            func_entry("a", vec![]),
704            func_entry("b", vec![("x".to_string(), ScSpecTypeDef::U32)]),
705        ]);
706        let spec = parse_contract_spec(&wasm).unwrap();
707        assert_eq!(spec.functions.len(), 2);
708        assert_eq!(spec.functions[0].name, "a");
709        assert_eq!(spec.functions[1].name, "b");
710        assert_eq!(spec.functions[1].parameters[0].type_.name, "u32");
711    }
712
713    #[test]
714    fn custom_type() {
715        use stellar_xdr::ScSpecUdtStructV0;
716        let udt = ScSpecEntry::UdtStructV0(ScSpecUdtStructV0 {
717            doc: "a point".try_into().unwrap(),
718            lib: "soroban_sdk".try_into().unwrap(),
719            name: "Point".try_into().unwrap(),
720            fields: vec![].try_into().unwrap(),
721        });
722        let wasm = spec_section(&[udt]);
723        let spec = parse_contract_spec(&wasm).unwrap();
724        assert_eq!(spec.custom_types.len(), 1);
725        assert_eq!(spec.custom_types[0].name, "Point");
726        assert_eq!(spec.custom_types[0].kind, "struct");
727        assert_eq!(spec.custom_types[0].doc, "a point");
728    }
729
730    #[test]
731    fn preserves_struct_union_and_event_metadata() {
732        use stellar_xdr::{
733            ScSpecEventDataFormat, ScSpecEventParamLocationV0, ScSpecEventParamV0, ScSpecEventV0,
734            ScSpecUdtStructFieldV0, ScSpecUdtStructV0, ScSpecUdtUnionCaseTupleV0,
735            ScSpecUdtUnionCaseV0, ScSpecUdtUnionV0,
736        };
737
738        let point = ScSpecEntry::UdtStructV0(ScSpecUdtStructV0 {
739            doc: "point".try_into().unwrap(),
740            lib: "test".try_into().unwrap(),
741            name: "Point".try_into().unwrap(),
742            fields: vec![ScSpecUdtStructFieldV0 {
743                doc: "x coordinate".try_into().unwrap(),
744                name: "x".try_into().unwrap(),
745                type_: ScSpecTypeDef::U64,
746            }]
747            .try_into()
748            .unwrap(),
749        });
750        let choice = ScSpecEntry::UdtUnionV0(ScSpecUdtUnionV0 {
751            doc: "choice".try_into().unwrap(),
752            lib: "test".try_into().unwrap(),
753            name: "Choice".try_into().unwrap(),
754            cases: vec![ScSpecUdtUnionCaseV0::TupleV0(ScSpecUdtUnionCaseTupleV0 {
755                doc: "some value".try_into().unwrap(),
756                name: "Some".try_into().unwrap(),
757                type_: vec![ScSpecTypeDef::String].try_into().unwrap(),
758            })]
759            .try_into()
760            .unwrap(),
761        });
762        let event = ScSpecEntry::EventV0(ScSpecEventV0 {
763            doc: "transfer event".try_into().unwrap(),
764            lib: "test".try_into().unwrap(),
765            name: symbol_e("Transfer"),
766            prefix_topics: vec![symbol_e("TOKEN")].try_into().unwrap(),
767            params: vec![ScSpecEventParamV0 {
768                doc: "amount".try_into().unwrap(),
769                name: "amount".try_into().unwrap(),
770                type_: ScSpecTypeDef::U128,
771                location: ScSpecEventParamLocationV0::Data,
772            }]
773            .try_into()
774            .unwrap(),
775            data_format: ScSpecEventDataFormat::SingleValue,
776        });
777
778        let spec = parse_contract_spec(&spec_section(&[point, choice, event])).unwrap();
779        assert_eq!(spec.custom_types[0].members[0].types[0].name, "u64");
780        assert_eq!(spec.custom_types[1].members[0].types[0].name, "string");
781        assert_eq!(spec.events[0].prefix_topics, vec!["TOKEN"]);
782        assert_eq!(spec.events[0].params[0].type_.name, "u128");
783        assert_eq!(spec.events[0].params[0].location, "data");
784        assert_eq!(spec.events[0].data_format, "single_value");
785    }
786
787    #[test]
788    fn preserves_compound_type_arguments_and_bytesn_length() {
789        use stellar_xdr::{ScSpecTypeBytesN, ScSpecTypeDef, ScSpecTypeMap, ScSpecTypeVec};
790
791        let vector = map_type_def(&ScSpecTypeDef::Vec(Box::new(ScSpecTypeVec {
792            element_type: Box::new(ScSpecTypeDef::U32),
793        })));
794        assert_eq!(vector.name, "vec<u32>");
795        assert_eq!(vector.type_args.len(), 1);
796        assert_eq!(vector.type_args[0].name, "u32");
797
798        let map = map_type_def(&ScSpecTypeDef::Map(Box::new(ScSpecTypeMap {
799            key_type: Box::new(ScSpecTypeDef::String),
800            value_type: Box::new(ScSpecTypeDef::Address),
801        })));
802        assert_eq!(
803            map.type_args
804                .iter()
805                .map(|t| t.name.as_str())
806                .collect::<Vec<_>>(),
807            ["string", "address"]
808        );
809
810        let bytes = map_type_def(&ScSpecTypeDef::BytesN(ScSpecTypeBytesN { n: 32 }));
811        assert_eq!(bytes.name, "bytesn<32>");
812        assert_eq!(bytes.bytes_n, Some(32));
813    }
814
815    #[test]
816    fn event_retains_params() {
817        // An `EventV0` entry keeps its full signature, not just its name.
818        let wasm = spec_section(&[event_entry_with_params(
819            "Transfer",
820            vec![
821                ("from".to_string(), ScSpecTypeDef::Address),
822                ("amount".to_string(), ScSpecTypeDef::I128),
823            ],
824        )]);
825        let spec = parse_contract_spec(&wasm).unwrap();
826        assert_eq!(spec.events.len(), 1);
827        let ev = &spec.events[0];
828        assert_eq!(ev.name, "Transfer");
829        assert_eq!(ev.data_format, "single_value");
830        assert!(ev.prefix_topics.is_empty());
831        assert_eq!(ev.params.len(), 2);
832        assert_eq!(ev.params[0].name, "from");
833        assert_eq!(ev.params[0].type_.name, "address");
834        assert_eq!(ev.params[0].location, "data");
835        assert_eq!(ev.params[1].name, "amount");
836        assert_eq!(ev.params[1].type_.name, "i128");
837    }
838
839    #[test]
840    fn udt_struct_retains_field_types() {
841        let wasm = spec_section(&[udt_struct_entry_with_fields(
842            "Point",
843            vec![("x".to_string(), ScSpecTypeDef::I32)],
844        )]);
845        let spec = parse_contract_spec(&wasm).unwrap();
846        assert_eq!(spec.custom_types.len(), 1);
847        assert_eq!(spec.custom_types[0].members.len(), 1);
848        assert_eq!(spec.custom_types[0].members[0].name, "x");
849        assert_eq!(spec.custom_types[0].members[0].types[0].name, "i32");
850    }
851
852    // ---- contractmetav0 (SEP-46 / SEP-58 provenance) ----
853    //
854    // Metadata is an open key/value space; these fixtures serialize real
855    // `SCMetaEntry` XDR values (stellar-xdr), never hand-rolled bytes, and one
856    // test runs against a REAL committed contract WASM carrying metadata.
857
858    fn meta_entry(key: &str, val: &str) -> ScMetaEntry {
859        ScMetaEntry::ScMetaV0(stellar_xdr::ScMetaV0 {
860            key: key.to_string().try_into().unwrap(),
861            val: val.to_string().try_into().unwrap(),
862        })
863    }
864
865    /// Encodes `SCMetaEntry` values into a bare `contractmetav0` payload.
866    fn encode_entries(entries: &[ScMetaEntry]) -> Vec<u8> {
867        let mut payload = Vec::new();
868        for e in entries {
869            let mut buf = Vec::new();
870            let mut cursor = Cursor::new(&mut buf);
871            let mut l = Limited::new(&mut cursor, Limits::none());
872            e.write_xdr(&mut l).unwrap();
873            payload.extend_from_slice(&buf);
874        }
875        payload
876    }
877
878    /// Wraps a `contractmetav0` payload in a real WASM custom section.
879    fn encode_meta_section(entries: &[ScMetaEntry]) -> Vec<u8> {
880        let mut section = Vec::new();
881        section.push(CONTRACT_META_V0.len() as u8);
882        section.extend_from_slice(CONTRACT_META_V0.as_bytes());
883        section.extend_from_slice(&encode_entries(entries));
884        let mut result = vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
885        result.push(0); // custom-section id
886        let mut sz = section.len() as u32;
887        let mut size_bytes = Vec::new();
888        while sz >= 0x80 {
889            size_bytes.push((sz as u8 & 0x7f) | 0x80);
890            sz >>= 7;
891        }
892        size_bytes.push(sz as u8);
893        result.extend_from_slice(&size_bytes);
894        result.extend_from_slice(&section);
895        result
896    }
897
898    #[test]
899    fn decode_contract_meta_reads_generic_key_values_in_order() {
900        let payload = vec![
901            meta_entry("rsver", "1.97.1"),
902            meta_entry("totally_unknown_key", "arbitrary value"),
903            meta_entry("cliver", "27.1.0#deadbeef"),
904        ];
905        let decoded = decode_contract_meta(&encode_entries(&payload)).unwrap();
906        assert_eq!(decoded.len(), 3);
907        assert_eq!(decoded[0].key, "rsver");
908        assert_eq!(decoded[0].value, "1.97.1");
909        assert_eq!(decoded[1].key, "totally_unknown_key");
910        assert_eq!(decoded[1].value, "arbitrary value");
911        assert_eq!(decoded[2].value, "27.1.0#deadbeef");
912    }
913
914    #[test]
915    fn decode_contract_meta_preserves_duplicate_keys() {
916        let payload = vec![
917            meta_entry("k", "first"),
918            meta_entry("k", "second"),
919            meta_entry("k", "third"),
920        ];
921        let decoded = decode_contract_meta(&encode_entries(&payload)).unwrap();
922        let values: Vec<&str> = decoded.iter().map(|e| e.value.as_str()).collect();
923        assert_eq!(values, vec!["first", "second", "third"]);
924    }
925
926    #[test]
927    fn decode_contract_meta_empty_payload_is_empty_list() {
928        assert!(decode_contract_meta(&[]).unwrap().is_empty());
929    }
930
931    #[test]
932    fn decode_contract_meta_rejects_malformed_payload() {
933        // 0xFF is not a valid SCMetaKind discriminant.
934        assert!(matches!(
935            decode_contract_meta(&[0xff, 0x00, 0x00]).unwrap_err(),
936            WasmError::MetaXdr(_)
937        ));
938        // A truncated string length must be rejected, not silently truncated.
939        assert!(matches!(
940            decode_contract_meta(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40]).unwrap_err(),
941            WasmError::MetaXdr(_)
942        ));
943    }
944
945    #[test]
946    fn parse_metadata_surfaces_meta_and_keeps_custom_sections_unchanged() {
947        let wasm = encode_meta_section(&[
948            meta_entry("rsver", "1.97.1"),
949            meta_entry("cliver", "27.1.0#8e402ea282029"),
950        ]);
951        let meta = crate::parse_metadata(&wasm).unwrap();
952        // Existing behavior preserved: section still listed by name.
953        assert_eq!(meta.custom_sections, vec![CONTRACT_META_V0.to_string()]);
954        assert_eq!(meta.contract_meta.len(), 2);
955        assert_eq!(meta.contract_meta[0].key, "rsver");
956        assert_eq!(meta.contract_meta[1].value, "27.1.0#8e402ea282029");
957    }
958
959    #[test]
960    fn parse_metadata_without_meta_section_is_empty() {
961        let meta = crate::parse_metadata(VALID_WASM).unwrap();
962        assert!(meta.contract_meta.is_empty());
963        assert!(meta.custom_sections.is_empty());
964    }
965
966    #[test]
967    fn parse_metadata_decodes_real_committed_contract_metadata() {
968        // `us_new.wasm` is a real compiled Soroban contract committed here; it
969        // genuinely carries `contractmetav0`.
970        let bytes = std::fs::read(concat!(
971            env!("CARGO_MANIFEST_DIR"),
972            "/../sdkt-cli/tests/fixtures/us_new.wasm"
973        ))
974        .expect("fixture present");
975        let meta = crate::parse_metadata(&bytes).unwrap();
976        assert!(meta.custom_sections.contains(&CONTRACT_META_V0.to_string()));
977        assert!(
978            !meta.contract_meta.is_empty(),
979            "real contract metadata must decode"
980        );
981        let get = |k: &str| {
982            meta.contract_meta
983                .iter()
984                .find(|e| e.key == k)
985                .map(|e| e.value.clone())
986        };
987        assert_eq!(get("rsver").as_deref(), Some("1.97.1"));
988        assert!(get("cliver").unwrap().starts_with("27.1.0#"));
989    }
990}