Skip to main content

sdkt_rpc/
inspect.rs

1use crate::client::SorobanRpcClient;
2use crate::error::RpcError;
3use crate::wasm::get_wasm_bytecode;
4use sdkt_wasm::{parse_contract_spec, ContractSpec};
5use sdkt_xdr::{
6    decode_contract_id, encode_ledger_key, extract_contract_data_value,
7    extract_contract_executable, parse_external_executable_hash, ContractExecutableRef,
8    ExternalExecutableRef, LedgerKeyParams,
9};
10use serde::Deserialize;
11use serde::Serialize;
12use stellar_xdr::{ContractDataDurability, ScAddress, ScVal};
13
14/// Normalize a user-supplied contract identifier into a 32-byte hex string
15/// suitable for [`encode_ledger_key`].
16///
17/// Accepts both the canonical StrKey form (`C...`) and a raw 32-byte hex string,
18/// so callers (CLI `inspect`, `events --abi-contract`, `storage --abi-contract`,
19/// and the storage TTL analyzer) can pass whichever form they already hold
20/// without pre-converting.
21///
22/// StrKey decoding is delegated to [`sdkt_xdr::decode_contract_id`], which maps
23/// `C...` -> `Hash` -> hex. A value that is already valid 32-byte hex is passed
24/// through unchanged.
25pub(crate) fn contract_id_to_hex(contract_id: &str) -> Result<String, RpcError> {
26    // Fast path: already a 32-byte hex string.
27    if let Ok(bytes) = hex::decode(contract_id) {
28        if bytes.len() == 32 {
29            return Ok(contract_id.to_string());
30        }
31    }
32    // Otherwise treat it as a StrKey `C...` and decode to the underlying hash.
33    let hash = decode_contract_id(contract_id)
34        .map_err(|e| RpcError::Rpc(format!("Invalid contract ID '{contract_id}': {e}")))?;
35    Ok(hex::encode(hash.0))
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
39pub struct ContractAbiSummary {
40    /// This reuses the existing `sdkt_wasm::parse_contract_spec` parser — it does NOT
41    /// introduce a new ABI decoder. It only lists the declared symbol names so the
42    /// on-chain inspection report can summarize a deployed contract's interface.
43    pub functions: Vec<String>,
44    pub events: Vec<String>,
45    pub types: Vec<String>,
46}
47
48impl ContractAbiSummary {
49    /// Project a parsed [`ContractSpec`] into the name-only summary.
50    pub fn from_spec(spec: &ContractSpec) -> Self {
51        Self {
52            functions: spec.functions.iter().map(|f| f.name.clone()).collect(),
53            events: spec.events.iter().map(|e| e.name.clone()).collect(),
54            types: spec.custom_types.iter().map(|t| t.name.clone()).collect(),
55        }
56    }
57}
58
59/// The result of a contract inspection.
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct ContractInspection {
62    pub contract_id: String,
63    pub wasm_hash: String,
64    pub wasm_size: Option<usize>,
65    /// Parsed on-chain ABI summary (functions / events / types). `None` when the
66    /// on-chain WASM code cannot be fetched or has no `contractspecv0` section.
67    pub abi: Option<ContractAbiSummary>,
68    pub storage_summary: StorageSummary,
69    pub ttl_info: Option<TtlInfoSummary>,
70    pub storage_keys: Vec<StorageKeyInfo>,
71}
72
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
74pub struct TtlInfoSummary {
75    pub minimum_ttl: u32,
76    pub maximum_ttl: u32,
77    pub average_ttl: u32,
78    pub expiring_entries_count: usize,
79    pub estimated_rent_cost: Option<u64>,
80}
81
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
83pub struct StorageSummary {
84    pub instance_entries: usize,
85    pub persistent_entries: usize,
86    pub temporary_entries: usize,
87}
88
89/// Metadata about a storage key discovered in the contract.
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct StorageKeyInfo {
92    pub key: String,
93    pub key_type: String,
94    pub permissions: String,
95}
96
97/// Inspects a deployed contract to extract its WASM hash, on-chain WASM size,
98/// and parsed ABI — reusing the existing `get_wasm_bytecode` + `parse_contract_spec`
99/// primitives. Storage/TTL/storage-key fields are left for the caller's layer
100/// (which has access to `StorageAnalyzer`) to populate, matching the existing
101/// architecture where storage analysis lives outside `sdkt-rpc`.
102///
103/// ## Protocol 28 (CAP-85) executables
104///
105/// A `ContractInstance`'s executable is a union, not necessarily an inline Wasm
106/// hash. `Wasm` behaves exactly as before. `StellarAsset` is protocol-defined
107/// code with no Wasm artifact, and `ExternalRef` (CAP-85) holds no code at
108/// all — it names an entry owned by another contract. An external reference is
109/// resolved to its current Wasm hash via a single read-only owner lookup
110/// ([`resolve_external_executable`]); a `StellarAsset` has no hash at all, so
111/// the inspection fails with an explicit error naming the executable kind
112/// instead of reporting a misleading value.
113///
114/// Failures to fetch/parse the on-chain WASM code degrade gracefully: the
115/// inspection still returns the `contract_id` + `wasm_hash` it already recovered,
116/// with `wasm_size`/`abi` left as `None`. Only the initial contract-data lookup
117/// failing (contract not on chain) is fatal.
118pub async fn inspect_contract(
119    client: &SorobanRpcClient,
120    contract_id: &str,
121) -> Result<ContractInspection, RpcError> {
122    let contract_id_hex = contract_id_to_hex(contract_id)?;
123    let encoded_key = encode_ledger_key(&LedgerKeyParams::ContractData(contract_id_hex.clone()))
124        .map_err(|e| RpcError::Rpc(format!("Failed to encode ledger key: {e}")))?;
125
126    let response = client
127        .get_contract_storage(contract_id, &[encoded_key])
128        .await?;
129
130    if response.entries.is_empty() {
131        return Err(RpcError::ContractNotFound);
132    }
133
134    let first_entry = &response.entries[0];
135
136    // Wasm executables resolve exactly as before. Non-Wasm variants get their
137    // own explicit, actionable message; the legacy decode-error wording is kept
138    // verbatim for undecodable entries so existing consumers are unaffected.
139    let wasm_hash = match resolve_inspection_wasm_hash(client, &first_entry.xdr).await {
140        Ok(hash) => hash,
141        Err(ExecutableResolutionError::Decode(e)) => {
142            return Err(RpcError::Rpc(format!("Failed to extract WASM hash: {e}")))
143        }
144        Err(e) => return Err(RpcError::Rpc(e.to_string())),
145    };
146
147    // Enrich with on-chain WASM size + parsed ABI. Both steps are best-effort:
148    // if the code entry is missing or unparseable, we keep what we have instead
149    // of failing the whole inspection.
150    let mut wasm_size = None;
151    let mut abi = None;
152    if let Ok(bytes) = get_wasm_bytecode(client, &wasm_hash).await {
153        wasm_size = Some(bytes.len());
154        if let Ok(spec) = parse_contract_spec(&bytes) {
155            abi = Some(ContractAbiSummary::from_spec(&spec));
156        }
157    }
158
159    Ok(ContractInspection {
160        contract_id: contract_id.to_string(),
161        wasm_hash,
162        wasm_size,
163        abi,
164        storage_summary: StorageSummary::default(),
165        ttl_info: None,
166        storage_keys: Vec::new(),
167    })
168}
169
170/// Outcome of a read-only deployed-executable probe.
171///
172/// Unlike [`inspect_contract`], this never fetches code or ABI and never
173/// converts a non-Wasm executable into an error: it reports exactly what the
174/// contract instance ledger entry says, so a caller (deployment verification)
175/// can distinguish `MATCH`-eligible data from an honest `UNKNOWN`.
176#[derive(Debug, Clone, PartialEq)]
177pub struct DeployedExecutable {
178    /// True when a contract-instance entry exists at this target. False means
179    /// the contract does not exist here — not an error, a verdict input.
180    pub instance_found: bool,
181    /// The executable as decoded from the instance entry, when decodable.
182    pub executable: Option<sdkt_xdr::ContractExecutableRef>,
183    /// The Wasm hash, when one exists authoritatively: an inline `Wasm`
184    /// executable's own hash, or a `ExternalRef` resolved through the existing
185    /// read-only owner lookup. `None` for `StellarAsset` (no artifact exists)
186    /// or when resolution failed.
187    pub wasm_hash: Option<String>,
188    /// Why the instance entry could not be decoded, when applicable.
189    pub decode_error: Option<String>,
190    /// Why an `ExternalRef` could not be resolved to a hash, when applicable.
191    pub resolve_error: Option<String>,
192    /// Transport/RPC error; `Err` means the probe could not run at all.
193    pub error: Option<String>,
194}
195
196/// Probe the deployed executable of `contract_id` with a single
197/// `getLedgerEntries` read of its instance singleton key (plus, for CAP-85
198/// `external_ref` executables only, the existing owner-entry lookup).
199///
200/// Read-only: no bytecode download, no ABI parse, no signing or submission.
201pub async fn probe_deployed_executable(
202    client: &SorobanRpcClient,
203    contract_id: &str,
204) -> Result<DeployedExecutable, RpcError> {
205    let contract_id_hex = contract_id_to_hex(contract_id)?;
206    let encoded_key = encode_ledger_key(&LedgerKeyParams::ContractData(contract_id_hex))
207        .map_err(|e| RpcError::Rpc(format!("Failed to encode ledger key: {e}")))?;
208
209    let response = client.get_contract_storage("", &[encoded_key]).await?;
210    if response.entries.is_empty() {
211        return Ok(DeployedExecutable {
212            instance_found: false,
213            executable: None,
214            wasm_hash: None,
215            decode_error: None,
216            resolve_error: None,
217            error: None,
218        });
219    }
220
221    let entry = &response.entries[0].xdr;
222    let executable = match extract_contract_executable(entry) {
223        Ok(exec) => Some(exec),
224        Err(e) => {
225            return Ok(DeployedExecutable {
226                instance_found: true,
227                executable: None,
228                wasm_hash: None,
229                decode_error: Some(format!("{e}")),
230                resolve_error: None,
231                error: None,
232            })
233        }
234    };
235
236    let mut wasm_hash = None;
237    let mut resolve_error = None;
238    if let Some(sdkt_xdr::ContractExecutableRef::Wasm(hash)) = &executable {
239        wasm_hash = Some(hex::encode(hash.0));
240    } else if let Some(r) = executable.as_ref().and_then(|e| e.executable_tag_key()) {
241        // CAP-85: resolve through the same read-only owner lookup `inspect`
242        // uses, so an unresolvable reference is reported as such rather than
243        // as a missing hash.
244        match resolve_external_executable(client, r).await {
245            Ok(hash) => wasm_hash = Some(hash),
246            Err(e) => resolve_error = Some(format!("{e}")),
247        }
248    }
249
250    Ok(DeployedExecutable {
251        instance_found: true,
252        executable,
253        wasm_hash,
254        decode_error: None,
255        resolve_error,
256        error: None,
257    })
258}
259
260/// Resolve the Wasm hash a contract instance's inspection should report.
261///
262/// `Wasm` executables are returned unchanged, preserving the previous behavior
263/// byte-for-byte. For the other union variants the instance holds no inline
264/// hash, so resolution is delegated to [`resolve_external_executable`] when
265/// possible; anything without a resolvable hash yields a typed
266/// [`ExecutableResolutionError`] instead of a fabricated value.
267async fn resolve_inspection_wasm_hash(
268    client: &SorobanRpcClient,
269    entry_xdr: &str,
270) -> Result<String, ExecutableResolutionError> {
271    let executable =
272        extract_contract_executable(entry_xdr).map_err(ExecutableResolutionError::Decode)?;
273    match executable {
274        ContractExecutableRef::Wasm(hash) => Ok(hex::encode(hash.0)),
275        ContractExecutableRef::StellarAsset => Err(ExecutableResolutionError::Unsupported {
276            kind: executable.kind(),
277        }),
278        ContractExecutableRef::ExternalRef(reference) => {
279            resolve_external_executable(client, &reference).await
280        }
281    }
282}
283
284/// Why an inspection or resolution could not produce a Wasm hash.
285///
286/// Public because it is the error type of [`resolve_external_executable`]; the
287/// `Display` output is the actionable message surfaced to CLI users.
288#[derive(Debug)]
289pub enum ExecutableResolutionError {
290    /// The ledger entry itself could not be decoded.
291    Decode(sdkt_xdr::DecodeError),
292    /// The executable is a valid non-Wasm variant with no inline hash.
293    Unsupported {
294        /// `stellar_asset` — protocol-defined code with no Wasm artifact.
295        kind: &'static str,
296    },
297    /// A CAP-85 external reference could not be resolved to a Wasm hash.
298    External {
299        /// Owner contract holding the executable entry.
300        owner: ScAddress,
301        /// The tag keying the owner's entry.
302        tag: String,
303        /// Why resolution failed.
304        source: String,
305    },
306}
307
308impl std::fmt::Display for ExecutableResolutionError {
309    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310        match self {
311            ExecutableResolutionError::Decode(e) => write!(f, "{e}"),
312            ExecutableResolutionError::Unsupported { kind } => write!(
313                f,
314                "contract executable is `{kind}` (Protocol 28): it is not a Wasm contract \
315                 and has no Wasm hash to report"
316            ),
317            ExecutableResolutionError::External { owner, tag, source } => write!(
318                f,
319                "could not resolve the CAP-85 external executable reference (owner {owner}, \
320                 tag \"{tag}\"): {source}"
321            ),
322        }
323    }
324}
325
326/// Resolve a CAP-85 `ExternalRef` to the Wasm hash it currently names.
327///
328/// CAP-0085 stores the referenced hash in a **persistent contract-data entry of
329/// the owner contract, keyed by the tag** (`SCV_EXECUTABLE_TAG`). This performs
330/// exactly that single read-only `getLedgerEntries` lookup; the owner contract
331/// is never invoked, and nothing is signed or submitted.
332pub async fn resolve_external_executable(
333    client: &SorobanRpcClient,
334    reference: &ExternalExecutableRef,
335) -> Result<String, ExecutableResolutionError> {
336    let owner = reference.owner.to_string();
337    let tag = reference.tag.clone();
338    let key = executable_tag_key(&reference.owner, &reference.tag).map_err(|source| {
339        ExecutableResolutionError::External {
340            owner: reference.owner.clone(),
341            tag: tag.clone(),
342            source,
343        }
344    })?;
345
346    let response = client
347        .get_contract_storage(&owner, &[key])
348        .await
349        .map_err(|e| ExecutableResolutionError::External {
350            owner: reference.owner.clone(),
351            tag: tag.clone(),
352            source: e.to_string(),
353        })?;
354
355    let entry = response
356        .entries
357        .first()
358        .ok_or_else(|| ExecutableResolutionError::External {
359            owner: reference.owner.clone(),
360            tag: tag.clone(),
361            source: format!(
362                "owner {owner} has no executable tag entry named \"{tag}\" (it may never have \
363                 been created, or the entry may have expired)"
364            ),
365        })?;
366
367    let value = extract_contract_data_value(&entry.xdr).map_err(|e| {
368        ExecutableResolutionError::External {
369            owner: reference.owner.clone(),
370            tag: tag.clone(),
371            source: format!("could not decode the owner's executable tag entry: {e}"),
372        }
373    })?;
374
375    parse_external_executable_hash(&value.val).map_err(|e| ExecutableResolutionError::External {
376        owner: reference.owner.clone(),
377        tag: tag.clone(),
378        source: e.to_string(),
379    })
380}
381
382/// Build the ledger key of a CAP-85 executable-tag entry.
383///
384/// The owner must be a contract address (only a contract can hold such an
385/// entry), and the tag travels as raw bytes because `SCString` is an opaque key
386/// that need not be valid UTF-8.
387fn executable_tag_key(owner: &ScAddress, tag: &str) -> Result<String, String> {
388    if !matches!(owner, ScAddress::Contract(_)) {
389        return Err(format!(
390            "executable owner {owner} is not a contract address; only a contract can hold an \
391             executable tag entry"
392        ));
393    }
394    let tag_bytes = stellar_xdr::StringM::try_from(tag.as_bytes().to_vec())
395        .map_err(|_| "executable tag exceeds the maximum ScString length".to_string())?;
396    encode_ledger_key(&LedgerKeyParams::ContractDataEntry {
397        contract: owner.to_string(),
398        key: ScVal::ExecutableTag(stellar_xdr::ScString(tag_bytes)),
399        durability: ContractDataDurability::Persistent,
400    })
401    .map_err(|e| e.to_string())
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[test]
409    fn abi_summary_from_spec_lists_names() {
410        // A spec with one function, one event, one type.
411        let spec = ContractSpec {
412            env_meta: None,
413            functions: vec![sdkt_wasm::ContractFunction {
414                name: "transfer".into(),
415                doc: String::new(),
416                parameters: vec![],
417                outputs: vec![],
418            }],
419            custom_types: vec![sdkt_wasm::ContractType {
420                name: "Asset".into(),
421                kind: "Struct".into(),
422                doc: String::new(),
423                members: vec![],
424                type_args: vec![],
425                bytes_n: None,
426            }],
427            events: vec![sdkt_wasm::ContractEvent {
428                name: "transfer".into(),
429                doc: String::new(),
430                params: vec![],
431                prefix_topics: vec![],
432                data_format: "single_value".into(),
433            }],
434        };
435        let summary = ContractAbiSummary::from_spec(&spec);
436        assert_eq!(summary.functions, vec!["transfer".to_string()]);
437        assert_eq!(summary.events, vec!["transfer".to_string()]);
438        assert_eq!(summary.types, vec!["Asset".to_string()]);
439    }
440
441    #[test]
442    fn abi_summary_default_is_empty() {
443        let s = ContractAbiSummary::default();
444        assert!(s.functions.is_empty());
445        assert!(s.events.is_empty());
446        assert!(s.types.is_empty());
447    }
448
449    #[test]
450    fn contract_id_to_hex_accepts_strkey_c_address() {
451        // Real testnet contract StrKey `C...` used by the / live validation.
452        let c = "CAE3U7JKESRWZHPEQ72DVNGOQ6WPA7HSPQZL5YV46NPCE4TMUPAGYMEC";
453        let hex = contract_id_to_hex(c).expect("StrKey C... should decode");
454        // 32-byte hash -> 64 hex chars.
455        assert_eq!(hex.len(), 64);
456        // Re-decoding the hex must be a no-op (already hex, passed through).
457        assert_eq!(contract_id_to_hex(&hex).unwrap(), hex);
458        // The decode must match the canonical hash of this contract.
459        assert_eq!(
460            hex,
461            "09ba7d2a24a36c9de487f43ab4ce87acf07cf27c32bee2bcf35e22726ca3c06c"
462        );
463    }
464
465    #[test]
466    fn contract_id_to_hex_rejects_garbage() {
467        assert!(contract_id_to_hex("not-a-contract-id").is_err());
468        // Valid-length hex but wrong type of strkey (account G...) must fail.
469        assert!(
470            contract_id_to_hex("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF").is_err()
471        );
472    }
473}