Skip to main content

sdkt_xdr/
lib.rs

1//! XDR decoding engine for Stellar and Soroban structures.
2//!
3//! Handles conversion of raw Base64, Hex, or raw-byte XDR payloads into
4//! standardized JSON formats (compact or pretty-printed).
5//!
6//! # Supported Types
7//!
8//! - `ScVal`
9//! - `TransactionEnvelope`
10//! - `TransactionResult`
11//! - `TransactionMeta`
12//! - `LedgerKey`
13//! - `LedgerEntry`
14//! - `ContractEvent` (auto + explicit)
15//!
16//! # Example
17//!
18//! ```rust
19//! use sdkt_xdr::decode;
20//!
21//! let b64 = "AAAAAQAAAAoAAAAA"; // ScVal::I32(1)
22//! let json = decode(b64, Some("scval"), sdkt_xdr::OutputFormat::Json).unwrap();
23//! println!("{}", json);
24//! ```
25
26pub mod builder;
27pub mod envelope;
28pub mod sign;
29pub mod typed;
30pub use builder::{
31    build_create_contract_tx, build_create_contract_tx_with_data,
32    build_create_contract_tx_with_data_and_auth, build_create_contract_v2_tx,
33    build_create_contract_v2_tx_with_data, build_create_contract_v2_tx_with_data_and_auth,
34    build_extend_footprint_tx, build_extend_footprint_tx_with_data, build_invoke_transaction,
35    build_invoke_transaction_with_data, build_restore_footprint_tx, build_upload_wasm_tx,
36    build_upload_wasm_tx_with_data, decode_account_id, decode_contract_id, decode_ledger_key,
37    derive_contract_id, memo_id, memo_text, merge_footprint_keys, parse_scval_args,
38    parse_soroban_transaction_data, CreateContractParams, CreateContractV2Params,
39    ExtendFootprintParams, InvokeTransactionParams, RestoreFootprintParams, UploadWasmParams,
40};
41pub use envelope::{decode_envelope, render_scval, view_envelope, EnvelopeView};
42pub use sign::{
43    sign_envelope_with, sign_transaction, verify_signature, Ed25519Signer, Network, Signer,
44    SigningError, SigningOptions,
45};
46pub use typed::{
47    decode_scvals, decode_scvals_ref, encode_scvals, json_args_to_base64, json_to_scval,
48    scval_from_base64, scval_to_base64, Address, FromScVal, IntoScVal, ScValError,
49};
50
51use base64::engine::general_purpose::STANDARD;
52use base64::Engine;
53use serde_json::Value;
54
55pub use sdkt_core::OutputFormat;
56
57use stellar_xdr::{
58    ContractDataDurability, ContractEvent, ContractExecutable, ContractId, Hash, LedgerEntry,
59    LedgerEntryData, LedgerKey, LedgerKeyContractCode, LedgerKeyContractData, Limited, Limits,
60    ReadXdr, ScAddress, ScSymbol, ScVal, ScVec, TransactionEnvelope, TransactionMeta,
61    TransactionResult, VecM, WriteXdr,
62};
63use thiserror::Error;
64
65/// Errors returned by the decoder.
66#[derive(Error, Debug)]
67pub enum DecodeError {
68    #[error("Base64 decode failed: {0}")]
69    Base64(#[from] base64::DecodeError),
70    #[error("Hex decode failed: {0}")]
71    Hex(#[from] hex::FromHexError),
72    #[error("XDR parse failed for type '{0}': {1}")]
73    XdrParse(String, stellar_xdr::Error),
74    #[error("XDR write failed: {0}")]
75    XdrWrite(stellar_xdr::Error),
76    #[error("Unknown XDR type: {0}")]
77    TypeUnknown(String),
78    #[error("Invalid input: empty payload")]
79    EmptyPayload,
80    #[error("JSON serialization failed: {0}")]
81    Json(#[from] serde_json::Error),
82    #[error("Extraction error: {0}")]
83    Extraction(String),
84}
85
86/// Parameters for constructing a `LedgerKey`.
87pub enum LedgerKeyParams {
88    /// A contract's instance data key. Takes the contract ID as a hex string.
89    ContractData(String),
90    /// A contract's WASM code key. Takes the WASM hash as a hex string.
91    ContractCode(String),
92    /// An arbitrary contract-data entry: the contract (`C...` StrKey or 32-byte
93    /// hex), the entry's key `ScVal`, and its durability. This is the general
94    /// form behind typed storage-key construction (e.g. a persistent map entry
95    /// keyed by `ScVec[symbol, address]`).
96    ContractDataEntry {
97        contract: String,
98        key: ScVal,
99        durability: ContractDataDurability,
100    },
101}
102
103/// Parse a contract identifier supplied as either a `C...` StrKey or a 32-byte
104/// hex string into its raw 32-byte contract ID.
105fn parse_contract_id_bytes(contract: &str) -> Result<[u8; 32], DecodeError> {
106    let trimmed = contract.trim();
107    if let Ok(stellar_strkey::Strkey::Contract(c)) = stellar_strkey::Strkey::from_string(trimmed) {
108        return Ok(c.0);
109    }
110    let bytes = hex::decode(trimmed).map_err(DecodeError::Hex)?;
111    if bytes.len() != 32 {
112        return Err(DecodeError::Extraction(
113            "contract must be a C... StrKey or 32-byte hex".to_string(),
114        ));
115    }
116    let mut id = [0u8; 32];
117    id.copy_from_slice(&bytes);
118    Ok(id)
119}
120
121/// Build a validated `ScVal::Symbol`.
122///
123/// Soroban `Symbol`s are restricted to <=32 chars of [a-zA-Z0-9_]. `ScSymbol`
124/// itself only enforces the length bound, so validate the charset here — an
125/// out-of-charset symbol would silently build a value no contract ever emits.
126pub fn symbol_scval(symbol: &str) -> Result<ScVal, DecodeError> {
127    if symbol.is_empty()
128        || symbol.len() > 32
129        || !symbol
130            .bytes()
131            .all(|b| b.is_ascii_alphanumeric() || b == b'_')
132    {
133        return Err(DecodeError::Extraction(format!(
134            "invalid symbol {symbol:?}: must be 1..=32 chars of [a-zA-Z0-9_]"
135        )));
136    }
137    let sym: ScSymbol = symbol.try_into().map_err(|_| {
138        DecodeError::Extraction(format!(
139            "invalid symbol {symbol:?}: must be <=32 chars of [a-zA-Z0-9_]"
140        ))
141    })?;
142    Ok(ScVal::Symbol(sym))
143}
144
145/// Encode an event-name symbol as the base64 XDR `ScVal` the RPC `getEvents`
146/// topic matcher compares against (e.g. `topic[0]` of a contract event).
147pub fn symbol_topic_base64(symbol: &str) -> Result<String, DecodeError> {
148    scval_to_base64(&symbol_scval(symbol)?)
149        .map_err(|e| DecodeError::Extraction(format!("failed to encode symbol {symbol:?}: {e}")))
150}
151
152/// Build a Soroban "enum/map"-style storage key `ScVal`: an `ScVec` whose first
153/// element is `symbol` and whose remaining elements are the decoded typed key
154/// arguments (each supplied as a Base64 `ScVal`).
155///
156/// This mirrors how the Soroban SDK encodes `DataKey`-style enum keys, e.g.
157/// `DataKey::Balance(addr)` → `ScVec[symbol("Balance"), address]`, so it also
158/// covers the single-symbol case (`ScVec[symbol("...")]`) when no args are given.
159pub fn build_map_key(symbol: &str, arg_scvals_b64: &[String]) -> Result<ScVal, DecodeError> {
160    let mut elems: Vec<ScVal> = Vec::with_capacity(1 + arg_scvals_b64.len());
161    elems.push(symbol_scval(symbol)?);
162    for b64 in arg_scvals_b64 {
163        let scval = scval_from_base64(b64).ok_or_else(|| {
164            DecodeError::Extraction(format!("invalid Base64 ScVal key argument: {b64}"))
165        })?;
166        elems.push(scval);
167    }
168    let vec: VecM<ScVal> = elems
169        .try_into()
170        .map_err(|_| DecodeError::Extraction("too many key arguments".to_string()))?;
171    Ok(ScVal::Vec(Some(ScVec(vec))))
172}
173
174/// Encodes `LedgerKeyParams` into a Base64 XDR `LedgerKey`.
175pub fn encode_ledger_key(params: &LedgerKeyParams) -> Result<String, DecodeError> {
176    let key = match params {
177        LedgerKeyParams::ContractData(contract_id_hex) => {
178            let hash_bytes = hex::decode(contract_id_hex).map_err(DecodeError::Hex)?;
179            if hash_bytes.len() != 32 {
180                return Err(DecodeError::Extraction(
181                    "Contract ID must be 32 bytes".to_string(),
182                ));
183            }
184            let mut contract_id = [0u8; 32];
185            contract_id.copy_from_slice(&hash_bytes);
186
187            LedgerKey::ContractData(LedgerKeyContractData {
188                contract: ScAddress::Contract(ContractId(Hash(contract_id))),
189                key: ScVal::LedgerKeyContractInstance,
190                durability: stellar_xdr::ContractDataDurability::Persistent,
191            })
192        }
193        LedgerKeyParams::ContractCode(wasm_hash_hex) => {
194            let hash_bytes = hex::decode(wasm_hash_hex).map_err(DecodeError::Hex)?;
195            if hash_bytes.len() != 32 {
196                return Err(DecodeError::Extraction(
197                    "WASM hash must be 32 bytes".to_string(),
198                ));
199            }
200            let mut wasm_hash = [0u8; 32];
201            wasm_hash.copy_from_slice(&hash_bytes);
202
203            LedgerKey::ContractCode(LedgerKeyContractCode {
204                hash: Hash(wasm_hash),
205            })
206        }
207        LedgerKeyParams::ContractDataEntry {
208            contract,
209            key,
210            durability,
211        } => {
212            let contract_id = parse_contract_id_bytes(contract)?;
213            LedgerKey::ContractData(LedgerKeyContractData {
214                contract: ScAddress::Contract(ContractId(Hash(contract_id))),
215                key: key.clone(),
216                durability: *durability,
217            })
218        }
219    };
220
221    let mut buf = Vec::new();
222    let mut l = Limited::new(&mut buf, Limits::none());
223    key.write_xdr(&mut l).map_err(DecodeError::XdrWrite)?;
224    Ok(STANDARD.encode(&buf))
225}
226
227/// Extracts the WASM hash from a Base64 encoded `LedgerEntry`.
228/// Traverses: LedgerEntry -> LedgerEntryData::ContractData -> ScVal::ContractInstance -> ContractExecutable::Wasm -> Hash
229///
230/// Compatibility bridge: some live Stellar/Soroban RPC endpoints (e.g. the current
231/// testnet) serialize `LedgerEntry` with the `data` union FIRST, whereas stellar-xdr
232/// 28.0.0 decodes `lastModifiedLedgerSeq` first. When the standard decode fails we
233/// fall back to [`extract_wasm_hash_from_live_ledger_entry`], which reads the
234/// `LedgerEntryData` union first and ignores the trailing ledger-seq/ext fields. This
235/// is intentionally scoped to ContractData/Wasm extraction only — it does NOT replace
236/// stellar-xdr's generic `LedgerEntry` decoder used elsewhere.
237pub fn extract_wasm_hash(base64_ledger_entry: &str) -> Result<String, DecodeError> {
238    let raw = detect_and_decode(base64_ledger_entry)?;
239
240    // Fast path: standard stellar-xdr 28.0.0 layout.
241    match extract_wasm_hash_standard(&raw) {
242        Ok(hash) => Ok(hash),
243        // Compatibility bridge: live data-first `LedgerEntry` wire layout.
244        // On failure, keep the original (standard) error so callers still see the
245        // expected `Extraction`/`XdrParse` classification for non-Wasm/non-contract
246        // entries.
247        Err(e) => match e {
248            DecodeError::XdrParse(..) => extract_wasm_hash_from_live_ledger_entry(&raw),
249            _ => Err(e),
250        },
251    }
252}
253
254/// Standard (stellar-xdr 28.0.0) `LedgerEntry` decode: lastModifiedLedgerSeq first.
255fn extract_wasm_hash_standard(raw: &[u8]) -> Result<String, DecodeError> {
256    let mut cursor = std::io::Cursor::new(raw);
257    let mut l = Limited::new(&mut cursor, Limits::none());
258    let entry = LedgerEntry::read_xdr(&mut l)
259        .map_err(|e| DecodeError::XdrParse("LedgerEntry".to_string(), e))?;
260
261    let data = match entry.data {
262        LedgerEntryData::ContractData(d) => d,
263        _ => return Err(DecodeError::Extraction("Not a ContractData entry".into())),
264    };
265    extract_hash_from_contract_data(data)
266}
267
268/// Live-wire compatibility decoder: reads the `LedgerEntryData` union FIRST (the order
269/// the live testnet RPC uses), then ignores any trailing `lastModifiedLedgerSeq`/ext
270/// bytes. Scope is strictly ContractData/Wasm hash extraction.
271pub fn extract_wasm_hash_from_live_ledger_entry(raw: &[u8]) -> Result<String, DecodeError> {
272    let mut cursor = std::io::Cursor::new(raw);
273    let mut l = Limited::new(&mut cursor, Limits::none());
274    let data = LedgerEntryData::read_xdr(&mut l)
275        .map_err(|e| DecodeError::XdrParse("LedgerEntryData(live)".to_string(), e))?;
276
277    let cd = match data {
278        LedgerEntryData::ContractData(d) => d,
279        _ => return Err(DecodeError::Extraction("Not a ContractData entry".into())),
280    };
281    extract_hash_from_contract_data(cd)
282}
283
284/// A contract's executable as recorded in its `ContractInstance` ledger entry.
285///
286/// Protocol 28 (CAP-0085) widened the `ContractExecutable` union beyond an
287/// inline Wasm hash, so "the contract's code" is no longer always a single
288/// `Hash`. This mirrors the XDR union faithfully; callers that need the actual
289/// code resolve the referenced entry through the RPC layer.
290#[derive(Debug, Clone, PartialEq, Eq)]
291pub enum ContractExecutableRef {
292    /// `CONTRACT_EXECUTABLE_WASM`: the instance's own Wasm hash.
293    Wasm(Hash),
294    /// `CONTRACT_EXECUTABLE_STELLAR_ASSET`: a Stellar Asset Contract. This is
295    /// protocol-defined code with no Wasm artifact of its own — there is no hash
296    /// to report and nothing to download.
297    StellarAsset,
298    /// `CONTRACT_EXECUTABLE_EXTERNAL_REF` (CAP-0085): the instance holds no
299    /// code; it references an entry owned by another contract.
300    ExternalRef(ExternalExecutableRef),
301}
302
303/// The owner + tag pair of a CAP-85 `CONTRACT_EXECUTABLE_EXTERNAL_REF`.
304///
305/// Per CAP-0085 the referenced Wasm hash lives in a **persistent contract-data
306/// entry of `executable_owner`, keyed by `tag`**, with the value being the 32
307/// raw hash bytes. The owner contract is not invoked for the lookup.
308#[derive(Debug, Clone, PartialEq, Eq)]
309pub struct ExternalExecutableRef {
310    /// Owner contract holding the executable entry (CAP-85 `executable_owner`).
311    pub owner: ScAddress,
312    /// The tag keying the owner's entry (CAP-85 `tag`, an `ScString`).
313    pub tag: String,
314}
315
316impl ContractExecutableRef {
317    /// Stable lowercase label (`wasm`, `stellar_asset`, `external_ref`).
318    pub fn kind(&self) -> &'static str {
319        match self {
320            ContractExecutableRef::Wasm(_) => "wasm",
321            ContractExecutableRef::StellarAsset => "stellar_asset",
322            ContractExecutableRef::ExternalRef(_) => "external_ref",
323        }
324    }
325
326    /// The ledger key of the owner's executable entry, for resolution.
327    ///
328    /// CAP-0085 keys this entry with `SCV_EXECUTABLE_TAG` and stores it as
329    /// `Persistent` contract data (the host rejects the `Temporary` and
330    /// `Instance` durabilities for such entries).
331    pub fn executable_tag_key(&self) -> Option<&ExternalExecutableRef> {
332        match self {
333            ContractExecutableRef::ExternalRef(r) => Some(r),
334            _ => None,
335        }
336    }
337}
338
339/// Decode a contract instance's executable out of a Base64 XDR `LedgerEntry`.
340///
341/// Accepts both wire layouts SDKT already tolerates (standard
342/// `lastModifiedLedgerSeq`-first, and the live data-first layout) and returns
343/// the union variant actually present. Non-`ContractInstance` payloads and
344/// undecodable data keep returning the existing [`DecodeError`] variants.
345pub fn extract_contract_executable(
346    base64_ledger_entry: &str,
347) -> Result<ContractExecutableRef, DecodeError> {
348    let raw = detect_and_decode(base64_ledger_entry)?;
349    match extract_executable_standard(&raw) {
350        Ok(exec) => Ok(exec),
351        Err(DecodeError::XdrParse(..)) => extract_executable_from_live_ledger_entry(&raw),
352        Err(e) => Err(e),
353    }
354}
355
356fn map_executable(executable: ContractExecutable) -> Result<ContractExecutableRef, DecodeError> {
357    Ok(match executable {
358        ContractExecutable::Wasm(h) => ContractExecutableRef::Wasm(h),
359        ContractExecutable::StellarAsset => ContractExecutableRef::StellarAsset,
360        ContractExecutable::ExternalRef(r) => {
361            ContractExecutableRef::ExternalRef(ExternalExecutableRef {
362                owner: r.executable_owner,
363                // `SCString` payload is not required to be valid UTF-8 (it is an
364                // opaque key); surface it losslessly rather than dropping bytes.
365                tag: String::from_utf8_lossy(r.tag.as_slice()).into_owned(),
366            })
367        }
368    })
369}
370
371fn executable_from_contract_data(
372    data: stellar_xdr::ContractDataEntry,
373) -> Result<ContractExecutableRef, DecodeError> {
374    let instance = match data.val {
375        ScVal::ContractInstance(i) => i,
376        _ => return Err(DecodeError::Extraction("Not a ContractInstance".into())),
377    };
378    map_executable(instance.executable)
379}
380
381fn extract_executable_standard(raw: &[u8]) -> Result<ContractExecutableRef, DecodeError> {
382    let mut cursor = std::io::Cursor::new(raw);
383    let mut l = Limited::new(&mut cursor, Limits::none());
384    let entry = LedgerEntry::read_xdr(&mut l)
385        .map_err(|e| DecodeError::XdrParse("LedgerEntry".to_string(), e))?;
386    let data = match entry.data {
387        LedgerEntryData::ContractData(d) => d,
388        _ => return Err(DecodeError::Extraction("Not a ContractData entry".into())),
389    };
390    executable_from_contract_data(data)
391}
392
393fn extract_executable_from_live_ledger_entry(
394    raw: &[u8],
395) -> Result<ContractExecutableRef, DecodeError> {
396    let mut cursor = std::io::Cursor::new(raw);
397    let mut l = Limited::new(&mut cursor, Limits::none());
398    let data = LedgerEntryData::read_xdr(&mut l)
399        .map_err(|e| DecodeError::XdrParse("LedgerEntryData(live)".to_string(), e))?;
400    let cd = match data {
401        LedgerEntryData::ContractData(d) => d,
402        _ => return Err(DecodeError::Extraction("Not a ContractData entry".into())),
403    };
404    executable_from_contract_data(cd)
405}
406
407/// Read the 32-byte Wasm hash stored in a CAP-85 executable-tag entry.
408///
409/// CAP-0085 constrains such an entry to a `Bytes` value holding the raw hash
410/// of an already-uploaded Wasm (the host rejects strings, integers, and any
411/// 32-byte value that is not a live Wasm hash). Anything else is rejected here
412/// rather than coerced.
413pub fn parse_external_executable_hash(value: &ScVal) -> Result<String, DecodeError> {
414    let ScVal::Bytes(bytes) = value else {
415        return Err(DecodeError::Extraction(format!(
416            "CAP-85 executable tag entry holds {}, expected a 32-byte Bytes Wasm hash",
417            value.name()
418        )));
419    };
420    let slice = bytes.as_slice();
421    if slice.len() != 32 {
422        return Err(DecodeError::Extraction(format!(
423            "CAP-85 executable tag entry holds {} bytes, expected 32",
424            slice.len()
425        )));
426    }
427    Ok(hex::encode(slice))
428}
429
430/// Shared tail of both decoders: ContractData -> ContractInstance -> Wasm hash.
431fn extract_hash_from_contract_data(
432    data: stellar_xdr::ContractDataEntry,
433) -> Result<String, DecodeError> {
434    let instance = match data.val {
435        ScVal::ContractInstance(i) => i,
436        _ => return Err(DecodeError::Extraction("Not a ContractInstance".into())),
437    };
438
439    let hash = match instance.executable {
440        ContractExecutable::Wasm(h) => h,
441        _ => return Err(DecodeError::Extraction("Not a Wasm executable".into())),
442    };
443
444    Ok(hex::encode(hash.0))
445}
446
447/// Extracts the generic `val: ScVal` and durability from a `LedgerEntry`
448/// containing a `ContractData` entry.
449///
450/// Uses the same live-wire compatibility bridge as [`extract_wasm_hash`].
451pub fn extract_contract_data_value(
452    base64_ledger_entry: &str,
453) -> Result<stellar_xdr::ContractDataEntry, DecodeError> {
454    let raw = detect_and_decode(base64_ledger_entry)?;
455    match extract_contract_data_value_standard(&raw) {
456        Ok(data) => Ok(data),
457        Err(e) => match e {
458            DecodeError::XdrParse(..) => extract_contract_data_value_from_live_ledger_entry(&raw),
459            _ => Err(e),
460        },
461    }
462}
463
464fn extract_contract_data_value_standard(
465    raw: &[u8],
466) -> Result<stellar_xdr::ContractDataEntry, DecodeError> {
467    let mut cursor = std::io::Cursor::new(raw);
468    let mut l = Limited::new(&mut cursor, Limits::none());
469    let entry = LedgerEntry::read_xdr(&mut l)
470        .map_err(|e| DecodeError::XdrParse("LedgerEntry".to_string(), e))?;
471    match entry.data {
472        LedgerEntryData::ContractData(d) => Ok(d),
473        _ => Err(DecodeError::Extraction("Not a ContractData entry".into())),
474    }
475}
476
477fn extract_contract_data_value_from_live_ledger_entry(
478    raw: &[u8],
479) -> Result<stellar_xdr::ContractDataEntry, DecodeError> {
480    let mut cursor = std::io::Cursor::new(raw);
481    let mut l = Limited::new(&mut cursor, Limits::none());
482    let data = LedgerEntryData::read_xdr(&mut l)
483        .map_err(|e| DecodeError::XdrParse("LedgerEntryData(live)".to_string(), e))?;
484    match data {
485        LedgerEntryData::ContractData(d) => Ok(d),
486        _ => Err(DecodeError::Extraction("Not a ContractData entry".into())),
487    }
488}
489
490/// Extracts the raw WASM bytecode from a Base64 encoded `LedgerEntry` containing a `ContractCode` entry.
491///
492/// Same compatibility bridge as [`extract_wasm_hash`]: tries the standard
493/// stellar-xdr 28.0.0 layout first, then falls back to the live data-first wire layout.
494pub fn extract_wasm_bytecode(base64_ledger_entry: &str) -> Result<Vec<u8>, DecodeError> {
495    let raw = detect_and_decode(base64_ledger_entry)?;
496
497    match extract_wasm_bytecode_standard(&raw) {
498        Ok(code) => Ok(code),
499        Err(e) => match e {
500            DecodeError::XdrParse(..) => extract_wasm_bytecode_from_live_ledger_entry(&raw),
501            _ => Err(e),
502        },
503    }
504}
505
506fn extract_wasm_bytecode_standard(raw: &[u8]) -> Result<Vec<u8>, DecodeError> {
507    let mut cursor = std::io::Cursor::new(raw);
508    let mut l = Limited::new(&mut cursor, Limits::none());
509    let entry = LedgerEntry::read_xdr(&mut l)
510        .map_err(|e| DecodeError::XdrParse("LedgerEntry".to_string(), e))?;
511
512    let code = match entry.data {
513        LedgerEntryData::ContractCode(c) => c,
514        _ => return Err(DecodeError::Extraction("Not a ContractCode entry".into())),
515    };
516    Ok(code.code.to_vec())
517}
518
519/// Live-wire compatibility decoder for `ContractCode` entries (data-first layout).
520pub fn extract_wasm_bytecode_from_live_ledger_entry(raw: &[u8]) -> Result<Vec<u8>, DecodeError> {
521    let mut cursor = std::io::Cursor::new(raw);
522    let mut l = Limited::new(&mut cursor, Limits::none());
523    let data = LedgerEntryData::read_xdr(&mut l)
524        .map_err(|e| DecodeError::XdrParse("LedgerEntryData(live)".to_string(), e))?;
525
526    let code = match data {
527        LedgerEntryData::ContractCode(c) => c,
528        _ => return Err(DecodeError::Extraction("Not a ContractCode entry".into())),
529    };
530    Ok(code.code.to_vec())
531}
532
533/// Decode a base64- or hex-encoded XDR payload to JSON.
534///
535/// `payload` is tried as base64 first; if that fails and the string is valid
536/// hex, it is decoded as hex. Raw-byte callers should use [`decode_bytes`].
537///
538/// # Arguments
539///
540/// * `payload` – base64 or hex encoded string
541/// * `type_hint` – explicit type (`"scval"`, etc.) or `None` for auto-detection
542/// * `format` – [`OutputFormat::Json`] or [`OutputFormat::Pretty`]
543///
544/// # Returns
545///
546/// JSON string representation of the decoded XDR.
547///
548/// # Errors
549///
550/// Returns [`DecodeError`] if the input is invalid or the XDR cannot be parsed.
551pub fn decode(
552    payload: &str,
553    type_hint: Option<&str>,
554    format: OutputFormat,
555) -> Result<String, DecodeError> {
556    let raw = detect_and_decode(payload)?;
557    let value = decode_bytes(&raw, type_hint)?;
558    format_json(&value, format)
559}
560
561/// Decode raw bytes (no base64/hex pre-processing).
562pub fn decode_bytes(raw: &[u8], type_hint: Option<&str>) -> Result<Value, DecodeError> {
563    if raw.is_empty() {
564        return Err(DecodeError::EmptyPayload);
565    }
566
567    let type_name = type_hint.unwrap_or("auto");
568
569    match type_name.to_lowercase().as_str() {
570        "scval" => decode_single::<ScVal>(raw, "ScVal"),
571        "transactionenvelope" => decode_single::<TransactionEnvelope>(raw, "TransactionEnvelope"),
572        "transactionresult" => decode_single::<TransactionResult>(raw, "TransactionResult"),
573        "transactionmeta" => decode_single::<TransactionMeta>(raw, "TransactionMeta"),
574        "ledgerkey" => decode_single::<LedgerKey>(raw, "LedgerKey"),
575        "ledgerentry" => decode_single::<LedgerEntry>(raw, "LedgerEntry"),
576        "contractevent" => decode_single::<ContractEvent>(raw, "ContractEvent"),
577        "auto" => auto_detect(raw),
578        other => Err(DecodeError::TypeUnknown(other.to_string())),
579    }
580}
581
582fn decode_single<T: ReadXdr + serde::Serialize>(
583    raw: &[u8],
584    name: &str,
585) -> Result<Value, DecodeError> {
586    let mut cursor = std::io::Cursor::new(raw);
587    let mut l = Limited::new(&mut cursor, Limits::none());
588    T::read_xdr(&mut l)
589        .map_err(|e| DecodeError::XdrParse(name.to_string(), e))
590        .and_then(|v| serde_json::to_value(&v).map_err(DecodeError::Json))
591}
592
593fn decode_single_strict<T: ReadXdr + serde::Serialize>(
594    raw: &[u8],
595    name: &str,
596) -> Result<Value, DecodeError> {
597    let mut cursor = std::io::Cursor::new(raw);
598    let mut l = Limited::new(&mut cursor, Limits::none());
599    T::read_xdr_to_end(&mut l)
600        .map_err(|e| DecodeError::XdrParse(name.to_string(), e))
601        .and_then(|v| serde_json::to_value(&v).map_err(DecodeError::Json))
602}
603
604fn auto_detect(raw: &[u8]) -> Result<Value, DecodeError> {
605    if let Ok(v) = decode_single_strict::<ScVal>(raw, "ScVal") {
606        return Ok(v);
607    }
608    if let Ok(v) = decode_single_strict::<TransactionEnvelope>(raw, "TransactionEnvelope") {
609        return Ok(v);
610    }
611    if let Ok(v) = decode_single_strict::<ContractEvent>(raw, "ContractEvent") {
612        return Ok(v);
613    }
614    if let Ok(v) = decode_single_strict::<TransactionResult>(raw, "TransactionResult") {
615        return Ok(v);
616    }
617    Err(DecodeError::TypeUnknown(
618        "auto-detection failed for all known types".to_string(),
619    ))
620}
621
622fn detect_and_decode(payload: &str) -> Result<Vec<u8>, DecodeError> {
623    if payload.is_empty() {
624        return Err(DecodeError::EmptyPayload);
625    }
626
627    let trimmed = payload.trim();
628
629    let base64_likely = !trimmed.is_empty()
630        && trimmed.len().is_multiple_of(4)
631        && trimmed
632            .chars()
633            .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=');
634
635    if base64_likely {
636        if let Ok(bytes) = STANDARD.decode(trimmed) {
637            return Ok(bytes);
638        }
639    }
640
641    if trimmed.len().is_multiple_of(2) && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
642        if let Ok(bytes) = hex::decode(trimmed) {
643            return Ok(bytes);
644        }
645    }
646
647    if base64_likely {
648        Err(DecodeError::Base64(base64::DecodeError::InvalidLength(0)))
649    } else {
650        Err(DecodeError::Hex(hex::FromHexError::InvalidHexCharacter {
651            c: trimmed
652                .chars()
653                .find(|c| !c.is_ascii_hexdigit())
654                .unwrap_or('?'),
655            index: 0,
656        }))
657    }
658}
659
660pub fn format_json(value: &Value, format: OutputFormat) -> Result<String, DecodeError> {
661    match format {
662        OutputFormat::Json => Ok(serde_json::to_string(value)?),
663        OutputFormat::Pretty => Ok(serde_json::to_string_pretty(value)?),
664    }
665}
666
667/// Estimate the serialized XDR size (in bytes) of a `WriteXdr` value.
668///
669/// Reuses the existing `WriteXdr` machinery — no duplicate parser. Returns the
670/// exact number of bytes the payload would occupy on the wire.
671pub fn estimate_xdr_size<T: WriteXdr>(value: &T) -> usize {
672    let mut buf = Vec::new();
673    let mut l = Limited::new(&mut buf, Limits::none());
674    // Best-effort: if serialization fails (e.g. value too large), report the
675    // buffer length so far. Callers use this only for pre-flight checks.
676    let _ = value.write_xdr(&mut l);
677    buf.len()
678}
679
680/// Validate that a raw byte payload is a well-formed XDR `TransactionEnvelope`.
681///
682/// Returns `Ok(size)` when the payload parses, or `Err` with a message when it
683/// does not. This is a pure structural check (no RPC).
684pub fn validate_xdr(raw: &[u8]) -> Result<usize, String> {
685    if raw.is_empty() {
686        return Err("empty payload".into());
687    }
688    let mut cursor = std::io::Cursor::new(raw);
689    let mut l = Limited::new(&mut cursor, Limits::none());
690    TransactionEnvelope::read_xdr(&mut l)
691        .map_err(|e| format!("malformed transaction envelope: {e}"))
692        .map(|_| raw.len())
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698    use stellar_xdr::{
699        ContractDataDurability, ContractDataEntry, ContractExecutable, ExtensionPoint, Hash,
700        LedgerEntry, LedgerEntryData, LedgerEntryExt, LedgerKey, ScAddress, ScContractInstance,
701        ScVal, TransactionResult, TransactionResultExt, TransactionResultResult, WriteXdr,
702    };
703
704    #[test]
705    fn test_invalid_base64() {
706        let result = decode("!!! ???", None, OutputFormat::default());
707        assert!(matches!(result, Err(DecodeError::Hex(_))));
708    }
709
710    #[test]
711    fn test_valid_base64_but_invalid_xdr() {
712        // Base64 for "hello world" which is not valid XDR
713        let result = decode("aGVsbG8gd29ybGQ=", None, OutputFormat::default());
714        assert!(matches!(result, Err(DecodeError::TypeUnknown(_))));
715    }
716
717    #[test]
718    fn test_valid_scval_integer_base64() {
719        let payload = "AAAABAAAAAE=";
720        let json = decode(payload, Some("scval"), OutputFormat::Json).unwrap();
721        let v: Value = serde_json::from_str(&json).unwrap();
722        assert!(v.is_object());
723        assert_eq!(v["i32"], 1);
724    }
725
726    #[test]
727    fn test_auto_decode_scval() {
728        let payload = "AAAABAAAAAE=";
729        let json = decode(payload, None, OutputFormat::Json).unwrap();
730        let v: Value = serde_json::from_str(&json).unwrap();
731        assert!(v.is_object());
732        assert_eq!(v["i32"], 1);
733    }
734
735    #[test]
736    fn test_auto_decode_transaction_result() {
737        let fee_charged = 9_000_000_000_000_i64;
738        let result = TransactionResult {
739            fee_charged,
740            result: TransactionResultResult::TxSuccess(vec![].try_into().unwrap()),
741            ext: TransactionResultExt::V0,
742        };
743        let bytes = result.to_xdr(Limits::none()).unwrap();
744        let payload = STANDARD.encode(bytes);
745        let json = decode(&payload, None, OutputFormat::Json).unwrap();
746        let v: Value = serde_json::from_str(&json).unwrap();
747
748        assert_eq!(v["fee_charged"], fee_charged.to_string());
749        assert!(v["result"]["tx_success"].is_array());
750    }
751
752    #[test]
753    fn test_auto_decode_small_transaction_result_not_scval() {
754        let fee_charged = 100_i64;
755        let result = TransactionResult {
756            fee_charged,
757            result: TransactionResultResult::TxSuccess(vec![].try_into().unwrap()),
758            ext: TransactionResultExt::V0,
759        };
760        let bytes = result.to_xdr(Limits::none()).unwrap();
761        let payload = STANDARD.encode(bytes);
762        let json = decode(&payload, None, OutputFormat::Json).unwrap();
763        let v: Value = serde_json::from_str(&json).unwrap();
764
765        assert_eq!(v["fee_charged"], fee_charged.to_string());
766        assert!(v["result"]["tx_success"].is_array());
767        assert!(v.get("i32").is_none());
768    }
769
770    #[test]
771    fn test_auto_decode_unknown_payload_still_returns_type_unknown() {
772        let payload = "aGVsbG8gd29ybGQ=";
773        let result = decode(payload, None, OutputFormat::default());
774        assert!(matches!(result, Err(DecodeError::TypeUnknown(_))));
775    }
776
777    #[test]
778    fn test_empty_payload() {
779        let result = decode("", None, OutputFormat::default());
780        assert!(matches!(result, Err(DecodeError::EmptyPayload)));
781    }
782
783    #[test]
784    fn test_unknown_type() {
785        let payload = "AAAABAAAAAE=";
786        let result = decode(payload, Some("nonexistent"), OutputFormat::default());
787        assert!(matches!(result, Err(DecodeError::TypeUnknown(_))));
788    }
789
790    #[test]
791    fn test_json_vs_pretty() {
792        let payload = "AAAABAAAAAE=";
793        let compact = decode(payload, Some("scval"), OutputFormat::Json).unwrap();
794        let pretty = decode(payload, Some("scval"), OutputFormat::Pretty).unwrap();
795        assert!(!compact.contains('\n'));
796        assert!(pretty.contains('\n'));
797    }
798
799    #[test]
800    fn test_build_map_key_symbol_and_u32() {
801        let u32_b64 = scval_to_base64(&ScVal::U32(100)).unwrap();
802        let key = build_map_key("balances", &[u32_b64]).unwrap();
803        match &key {
804            ScVal::Vec(Some(v)) => {
805                assert_eq!(v.len(), 2);
806                assert_eq!(v[0], ScVal::Symbol("balances".try_into().unwrap()));
807                assert_eq!(v[1], ScVal::U32(100));
808            }
809            other => panic!("expected ScVec, got {other:?}"),
810        }
811    }
812
813    #[test]
814    fn test_symbol_topic_base64_encodes_scval_symbol() {
815        // SCV_SYMBOL (15) + length 8 + "Transfer" (already 4-byte aligned).
816        let b64 = symbol_topic_base64("Transfer").unwrap();
817        assert_eq!(b64, "AAAADwAAAAhUcmFuc2Zlcg==");
818        assert_eq!(
819            scval_from_base64(&b64),
820            Some(ScVal::Symbol("Transfer".try_into().unwrap()))
821        );
822        // Symbols are case-sensitive on the wire.
823        assert_eq!(
824            symbol_topic_base64("transfer").unwrap(),
825            "AAAADwAAAAh0cmFuc2Zlcg=="
826        );
827    }
828
829    #[test]
830    fn test_symbol_topic_base64_rejects_invalid_symbols() {
831        for bad in ["", "has space", "dash-ed", "é", &"a".repeat(33)] {
832            let err = symbol_topic_base64(bad).unwrap_err();
833            assert!(
834                err.to_string().contains("invalid symbol"),
835                "unexpected error for {bad:?}: {err}"
836            );
837        }
838        assert!(symbol_topic_base64(&"a".repeat(32)).is_ok());
839    }
840
841    #[test]
842    fn test_build_map_key_symbol_only_and_composite() {
843        // Single symbol -> ScVec[symbol]
844        let single = build_map_key("admin", &[]).unwrap();
845        match &single {
846            ScVal::Vec(Some(v)) => {
847                assert_eq!(v.len(), 1);
848                assert_eq!(v[0], ScVal::Symbol("admin".try_into().unwrap()));
849            }
850            other => panic!("expected ScVec, got {other:?}"),
851        }
852        // Composite symbol + two typed args
853        let a = scval_to_base64(&ScVal::U32(1)).unwrap();
854        let b = scval_to_base64(&ScVal::U32(2)).unwrap();
855        let composite = build_map_key("pair", &[a, b]).unwrap();
856        match &composite {
857            ScVal::Vec(Some(v)) => assert_eq!(v.len(), 3),
858            other => panic!("expected ScVec, got {other:?}"),
859        }
860    }
861
862    #[test]
863    fn test_encode_contract_data_entry_durability_changes_key() {
864        let contract = "0000000000000000000000000000000000000000000000000000000000000000";
865        let key = build_map_key("k", &[scval_to_base64(&ScVal::U32(7)).unwrap()]).unwrap();
866        let persistent = encode_ledger_key(&LedgerKeyParams::ContractDataEntry {
867            contract: contract.to_string(),
868            key: key.clone(),
869            durability: ContractDataDurability::Persistent,
870        })
871        .unwrap();
872        let temporary = encode_ledger_key(&LedgerKeyParams::ContractDataEntry {
873            contract: contract.to_string(),
874            key: key.clone(),
875            durability: ContractDataDurability::Temporary,
876        })
877        .unwrap();
878        assert_ne!(
879            persistent, temporary,
880            "durability must produce a different LedgerKey"
881        );
882
883        let decoded = decode_ledger_key(&persistent).unwrap();
884        match decoded {
885            LedgerKey::ContractData(d) => {
886                assert_eq!(d.durability, ContractDataDurability::Persistent);
887                assert_eq!(d.key, key);
888            }
889            other => panic!("expected ContractData, got {other:?}"),
890        }
891    }
892
893    #[test]
894    fn test_encode_contract_data_entry_accepts_strkey_and_hex() {
895        let id = [3u8; 32];
896        let hex_id = hex::encode(id);
897        let strkey = format!("{}", stellar_strkey::Contract(id));
898        let from_hex = encode_ledger_key(&LedgerKeyParams::ContractDataEntry {
899            contract: hex_id,
900            key: ScVal::LedgerKeyContractInstance,
901            durability: ContractDataDurability::Persistent,
902        })
903        .unwrap();
904        let from_strkey = encode_ledger_key(&LedgerKeyParams::ContractDataEntry {
905            contract: strkey,
906            key: ScVal::LedgerKeyContractInstance,
907            durability: ContractDataDurability::Persistent,
908        })
909        .unwrap();
910        assert_eq!(from_hex, from_strkey);
911    }
912
913    // ---- CAP-85 (Protocol 28) executable variants ----
914    //
915    // `ContractExecutable` is a Protocol 28 XDR union. These fixtures are built
916    // with the real `stellar-xdr` types and round-tripped through the same
917    // decoders the RPC inspection path uses, so they exercise the wire
918    // representation rather than a hand-written enum value.
919
920    /// Owner address used by the external-ref fixtures.
921    fn external_ref_fixture() -> (ContractExecutable, ScAddress) {
922        let owner = ScAddress::Contract(ContractId(Hash([7u8; 32])));
923        let tag =
924            stellar_xdr::ScString(stellar_xdr::StringM::try_from(b"oracle-v1".to_vec()).unwrap());
925        (
926            ContractExecutable::ExternalRef(stellar_xdr::ContractExecutableExternalRef {
927                executable_owner: owner.clone(),
928                tag,
929            }),
930            owner,
931        )
932    }
933
934    #[test]
935    fn test_extract_contract_executable_wasm() {
936        let hash = [9u8; 32];
937        let b64 = create_test_ledger_entry(ContractExecutable::Wasm(Hash(hash)));
938        let exec = extract_contract_executable(&b64).unwrap();
939        assert_eq!(exec.kind(), "wasm");
940        assert_eq!(exec, ContractExecutableRef::Wasm(Hash(hash)));
941        // The pre-existing Wasm-only extraction must keep reporting the same hash.
942        assert_eq!(extract_wasm_hash(&b64).unwrap(), hex::encode(hash));
943    }
944
945    #[test]
946    fn test_extract_contract_executable_stellar_asset_is_not_wasm() {
947        let b64 = create_test_ledger_entry(ContractExecutable::StellarAsset);
948        let exec = extract_contract_executable(&b64).unwrap();
949        assert_eq!(exec, ContractExecutableRef::StellarAsset);
950        assert_eq!(exec.kind(), "stellar_asset");
951        // A SAC must never be reported as a Wasm contract.
952        assert!(!matches!(exec, ContractExecutableRef::Wasm(_)));
953        assert!(exec.executable_tag_key().is_none());
954    }
955
956    #[test]
957    fn test_extract_contract_executable_external_ref() {
958        let (executable, owner) = external_ref_fixture();
959        let b64 = create_test_ledger_entry(executable);
960        let exec = extract_contract_executable(&b64).unwrap();
961        assert_eq!(exec.kind(), "external_ref");
962        let reference = exec.executable_tag_key().expect("external ref key");
963        assert_eq!(reference.owner, owner);
964        assert_eq!(reference.tag, "oracle-v1");
965        // An external ref has no inline hash: the legacy Wasm-only extractor must
966        // keep failing loudly rather than inventing one.
967        assert!(extract_wasm_hash(&b64).is_err());
968    }
969
970    #[test]
971    fn test_external_ref_survives_live_wire_layout() {
972        // The live RPC wire layout serialises `LedgerEntryData` first (with the
973        // remaining `LedgerEntry` fields trailing) — the layout the compatibility
974        // bridge exists for. Build exactly that: `LedgerEntryData` only.
975        let (executable, owner) = external_ref_fixture();
976        let data = LedgerEntryData::ContractData(ContractDataEntry {
977            ext: ExtensionPoint::V0,
978            contract: ScAddress::Contract(ContractId(Hash([0; 32]))),
979            key: ScVal::LedgerKeyContractInstance,
980            durability: ContractDataDurability::Persistent,
981            val: ScVal::ContractInstance(ScContractInstance {
982                executable,
983                storage: None,
984            }),
985        });
986        let mut buf = Vec::new();
987        let mut cursor = std::io::Cursor::new(&mut buf);
988        let mut l = Limited::new(&mut cursor, Limits::none());
989        data.write_xdr(&mut l).unwrap();
990
991        let exec = extract_executable_from_live_ledger_entry(&buf).unwrap();
992        assert_eq!(exec.kind(), "external_ref");
993        assert_eq!(exec.executable_tag_key().unwrap().owner, owner);
994        assert_eq!(exec.executable_tag_key().unwrap().tag, "oracle-v1");
995    }
996
997    #[test]
998    fn test_parse_external_executable_hash_accepts_32_bytes() {
999        let hash = [3u8; 32];
1000        let val = ScVal::Bytes(stellar_xdr::ScBytes(
1001            stellar_xdr::BytesM::try_from(hash.to_vec()).unwrap(),
1002        ));
1003        assert_eq!(
1004            parse_external_executable_hash(&val).unwrap(),
1005            hex::encode(hash)
1006        );
1007    }
1008
1009    #[test]
1010    fn test_parse_external_executable_hash_rejects_non_hash_values() {
1011        // CAP-0085 constrains the entry to raw 32-byte hash bytes; the host
1012        // rejects these shapes, and so must we (no silent coercion).
1013        let short = ScVal::Bytes(stellar_xdr::ScBytes(
1014            stellar_xdr::BytesM::try_from(vec![1u8; 31]).unwrap(),
1015        ));
1016        assert!(parse_external_executable_hash(&short).is_err());
1017
1018        let as_string = ScVal::String(stellar_xdr::ScString(
1019            stellar_xdr::StringM::try_from(vec![1u8; 32]).unwrap(),
1020        ));
1021        assert!(parse_external_executable_hash(&as_string).is_err());
1022
1023        assert!(parse_external_executable_hash(&ScVal::U32(5)).is_err());
1024    }
1025
1026    #[test]
1027    fn test_extract_contract_executable_rejects_non_contract_data() {
1028        let err = extract_contract_executable("AAAABAAAAAE=").unwrap_err();
1029        assert!(matches!(err, DecodeError::XdrParse(_, _)));
1030    }
1031
1032    #[test]
1033    fn test_build_map_key_rejects_invalid_symbol_and_arg() {
1034        assert!(build_map_key("has spaces", &[]).is_err());
1035        assert!(build_map_key("k", &["!!!not-base64!!!".to_string()]).is_err());
1036    }
1037
1038    #[test]
1039    fn test_encode_contract_data_entry_rejects_bad_contract() {
1040        let err = encode_ledger_key(&LedgerKeyParams::ContractDataEntry {
1041            contract: "not-a-contract".to_string(),
1042            key: ScVal::LedgerKeyContractInstance,
1043            durability: ContractDataDurability::Persistent,
1044        });
1045        assert!(err.is_err());
1046    }
1047
1048    #[test]
1049    fn test_encode_ledger_key() {
1050        let contract_id = "0000000000000000000000000000000000000000000000000000000000000000";
1051        let res =
1052            encode_ledger_key(&LedgerKeyParams::ContractData(contract_id.to_string())).unwrap();
1053        // Decode it back to verify it's a LedgerKey::ContractData with ScVal::LedgerKeyContractInstance
1054        let decoded = detect_and_decode(&res).unwrap();
1055        let mut cursor = std::io::Cursor::new(&decoded);
1056        let mut l = Limited::new(&mut cursor, Limits::none());
1057        let lk = LedgerKey::read_xdr(&mut l).unwrap();
1058        match lk {
1059            LedgerKey::ContractData(d) => {
1060                assert_eq!(d.key, ScVal::LedgerKeyContractInstance);
1061            }
1062            _ => panic!("Expected ContractData"),
1063        }
1064    }
1065
1066    fn create_test_ledger_entry(executable: ContractExecutable) -> String {
1067        let entry = LedgerEntry {
1068            last_modified_ledger_seq: 1,
1069            data: LedgerEntryData::ContractData(ContractDataEntry {
1070                ext: ExtensionPoint::V0,
1071                contract: ScAddress::Contract(ContractId(Hash([0; 32]))),
1072                key: ScVal::LedgerKeyContractInstance,
1073                durability: ContractDataDurability::Persistent,
1074                val: ScVal::ContractInstance(ScContractInstance {
1075                    executable,
1076                    storage: None,
1077                }),
1078            }),
1079            ext: LedgerEntryExt::V0,
1080        };
1081        let mut buf = Vec::new();
1082        let mut cursor = std::io::Cursor::new(&mut buf);
1083        let mut l = Limited::new(&mut cursor, Limits::none());
1084        entry.write_xdr(&mut l).unwrap();
1085        STANDARD.encode(&buf)
1086    }
1087
1088    #[test]
1089    fn test_extract_wasm_hash_valid() {
1090        let hash = [1u8; 32];
1091        let b64 = create_test_ledger_entry(ContractExecutable::Wasm(Hash(hash)));
1092        let extracted = extract_wasm_hash(&b64).unwrap();
1093        assert_eq!(extracted, hex::encode(hash));
1094    }
1095
1096    #[test]
1097    fn test_extract_wasm_hash_non_wasm() {
1098        let b64 = create_test_ledger_entry(ContractExecutable::StellarAsset);
1099        let err = extract_wasm_hash(&b64).unwrap_err();
1100        assert!(matches!(err, DecodeError::Extraction(e) if e.contains("Not a Wasm")));
1101    }
1102
1103    #[test]
1104    fn test_extract_wasm_hash_non_ledger_entry() {
1105        // Just an ScVal
1106        let b64 = "AAAABAAAAAE=";
1107        let err = extract_wasm_hash(b64).unwrap_err();
1108        assert!(matches!(err, DecodeError::XdrParse(_, _)));
1109    }
1110
1111    #[test]
1112    fn test_extract_wasm_hash_malformed_base64() {
1113        let err = extract_wasm_hash("invalid base64!!!").unwrap_err();
1114        assert!(matches!(err, DecodeError::Hex(_))); // detect_and_decode falls back to Hex and fails there
1115    }
1116
1117    // Captured real `LedgerEntry` XDR from the live Stellar/Soroban testnet.
1118    //
1119    // CAE3U7JKESRWZHPEQ72DVNGOQ6WPA7HSPQZL5YV46NPCE4TMUPAGYMEC — a deployed Wasm
1120    // (Soroban) contract. Its `LedgerEntry` uses the LIVE wire layout where
1121    // `LedgerEntryData` is serialized FIRST (data union discriminant 6 =
1122    // CONTRACT_DATA), unlike stellar-xdr 28.0.0's lastModifiedLedgerSeq-first
1123    // `LedgerEntry`. The `val` is a `ContractInstance` whose `executable` is
1124    // `ContractExecutable::Wasm`, so the compatibility bridge must extract the Wasm
1125    // hash `60cddae6...`. Used to prove `extract_wasm_hash_from_live_ledger_entry`
1126    // works without network access.
1127    const LIVE_WASM_LEDGER_ENTRY_B64: &str = "AAAABgAAAAAAAAABCbp9KiSjbJ3kh/Q6tM6HrPB88nwyvuK8814icmyjwGwAAAAUAAAAAQAAABMAAAAAYM3a5n8gLBnuewAMiU/RKqi0TeCatlL14Yi8DGOmzwIAAAABAAAABwAAABAAAAABAAAAAQAAAA8AAAAFQWRtaW4AAAAAAAASAAAAAAAAAACUijpq22w97c0/5wJEUlkFddoMBv/zyTHhNMr9T4FBBQAAABAAAAABAAAAAQAAAA8AAAAGQ29uZmlnAAAAAAARAAAAAQAAAAsAAAAPAAAAFWJhc2VfZnVuZGluZ19yYXRlX2JwcwAAAAAAAAMAAAABAAAADwAAABJiYXNlX21ha2VyX2ZlZV9icHMAAAAAAAMAAAACAAAADwAAABJiYXNlX3Rha2VyX2ZlZV9icHMAAAAAAAMAAAAFAAAADwAAABNsaXF1aWRhdGlvbl9mZWVfYnBzAAAAAAMAAAH0AAAADwAAABZtYWludGVuYW5jZV9tYXJnaW5fYnBzAAAAAAADAAAAZAAAAA8AAAAMbWF4X2xldmVyYWdlAAAAAwAAAAoAAAAPAAAAGG1heF9vcmFjbGVfZGV2aWF0aW9uX2JwcwAAAAMAAABkAAAADwAAABFtYXhfcG9zaXRpb25fc2l6ZQAAAAAAAAoAAAAAAAAAAAAAAOjUpRAAAAAADwAAABNtYXhfcHJpY2Vfc3RhbGVuZXNzAAAAAAUAAAAAAAAAPAAAAA8AAAAObWluX2NvbGxhdGVyYWwAAAAAAAoAAAAAAAAAAAAAAAAF9eEAAAAADwAAAA90cmFkaW5nX2ZlZV9icHMAAAAAAwAAAAoAAAAQAAAAAQAAAAEAAAAPAAAAC0luaXRpYWxpemVkAAAAAAAAAAABAAAAEAAAAAEAAAABAAAADwAAAA1PcmFjbGVBZGFwdGVyAAAAAAAAEgAAAAFxzC34I3pmV4llcmFKnN7k7qZkMKXvGRPZMuobQm6naQAAABAAAAABAAAAAQAAAA8AAAAGUGF1c2VkAAAAAAAAAAAAAQAAABAAAAABAAAAAQAAAA8AAAAJVXNkY1Rva2VuAAAAAAAAEgAAAAE9sj2cIS9K0A0viwokid/jaOEXECNi3xRj3/ivNitXOgAAABAAAAABAAAAAQAAAA8AAAAFVmF1bHQAAAAAAAASAAAAAVdc5734Fh3zRgwQwWibRi6egIv95VY++/CqtURwMM+t";
1128
1129    // Captured real `LedgerEntry` XDR for a Stellar Asset Contract (SAC) on testnet
1130    // (CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC). It uses the same
1131    // live data-first wire layout, but its `executable` is `ContractExecutable::
1132    // StellarAsset`, NOT `Wasm`. Proves the bridge correctly identifies a non-Wasm
1133    // contract and returns a controlled `Extraction` error (not an XDR panic).
1134    const LIVE_SAC_LEDGER_ENTRY_B64: &str = "AAAABgAAAAAAAAAB15KLcsJwPM/q9+uf9O9NUEpVqLl5/JtFDqLIQrTRzmEAAAAUAAAAAQAAABMAAAABAAAAAQAAAAIAAAAPAAAACE1FVEFEQVRBAAAAEQAAAAEAAAADAAAADwAAAAdkZWNpbWFsAAAAAAMAAAAHAAAADwAAAARuYW1lAAAADgAAAAZuYXRpdmUAAAAAAA8AAAAGc3ltYm9sAAAAAAAOAAAABm5hdGl2ZQAAAAAAEAAAAAEAAAABAAAADwAAAAlBc3NldEluZm8AAAAAAAAQAAAAAQAAAAEAAAAPAAAABk5hdGl2ZQAA";
1135
1136    #[test]
1137    fn test_extract_wasm_hash_live_wasm_wire_layout() {
1138        // The live entry must NOT decode via the standard (lastModifiedLedgerSeq-first)
1139        // path, proving the captured wire layout genuinely differs.
1140        let raw = detect_and_decode(LIVE_WASM_LEDGER_ENTRY_B64).unwrap();
1141        assert!(extract_wasm_hash_standard(&raw).is_err());
1142
1143        // The compatibility bridge decodes it and extracts the correct Wasm hash.
1144        let hash = extract_wasm_hash_from_live_ledger_entry(&raw).unwrap();
1145        assert_eq!(
1146            hash,
1147            "60cddae67f202c19ee7b000c894fd12aa8b44de09ab652f5e188bc0c63a6cf02"
1148        );
1149
1150        // And the public entry point falls back to the bridge automatically.
1151        let via_public = extract_wasm_hash(LIVE_WASM_LEDGER_ENTRY_B64).unwrap();
1152        assert_eq!(via_public, hash);
1153    }
1154
1155    #[test]
1156    fn test_extract_wasm_hash_live_sac_is_controlled_error() {
1157        // A live SAC (StellarAsset executable) decodes structurally but yields a
1158        // controlled `Extraction` error — never an XDR panic or a wrong hash.
1159        let raw = detect_and_decode(LIVE_SAC_LEDGER_ENTRY_B64).unwrap();
1160        let err = extract_wasm_hash_from_live_ledger_entry(&raw).unwrap_err();
1161        assert!(matches!(err, DecodeError::Extraction(e) if e.contains("Not a Wasm")));
1162
1163        // The public entry point returns the same controlled error.
1164        let err = extract_wasm_hash(LIVE_SAC_LEDGER_ENTRY_B64).unwrap_err();
1165        assert!(matches!(err, DecodeError::Extraction(e) if e.contains("Not a Wasm")));
1166    }
1167
1168    #[test]
1169    fn test_extract_wasm_hash_live_wire_truncated_fails_controlled() {
1170        // Drop the last 4 base64 chars -> partial trailing bytes -> controlled XdrParse,
1171        // never a panic.
1172        let truncated = &LIVE_WASM_LEDGER_ENTRY_B64[..LIVE_WASM_LEDGER_ENTRY_B64.len() - 4];
1173        let err = extract_wasm_hash(truncated).unwrap_err();
1174        assert!(matches!(err, DecodeError::XdrParse(_, _)));
1175    }
1176
1177    #[test]
1178    fn test_estimate_xdr_size() {
1179        let val = ScVal::U32(42);
1180        // Tag(4) + U32(4) = 8 bytes
1181        assert_eq!(estimate_xdr_size(&val), 8);
1182    }
1183}
1184
1185pub mod abi_decode;