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
14pub(crate) fn contract_id_to_hex(contract_id: &str) -> Result<String, RpcError> {
26 if let Ok(bytes) = hex::decode(contract_id) {
28 if bytes.len() == 32 {
29 return Ok(contract_id.to_string());
30 }
31 }
32 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 pub functions: Vec<String>,
44 pub events: Vec<String>,
45 pub types: Vec<String>,
46}
47
48impl ContractAbiSummary {
49 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#[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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct StorageKeyInfo {
92 pub key: String,
93 pub key_type: String,
94 pub permissions: String,
95}
96
97pub 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 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 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#[derive(Debug, Clone, PartialEq)]
177pub struct DeployedExecutable {
178 pub instance_found: bool,
181 pub executable: Option<sdkt_xdr::ContractExecutableRef>,
183 pub wasm_hash: Option<String>,
188 pub decode_error: Option<String>,
190 pub resolve_error: Option<String>,
192 pub error: Option<String>,
194}
195
196pub 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 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
260async 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#[derive(Debug)]
289pub enum ExecutableResolutionError {
290 Decode(sdkt_xdr::DecodeError),
292 Unsupported {
294 kind: &'static str,
296 },
297 External {
299 owner: ScAddress,
301 tag: String,
303 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
326pub 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
382fn 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 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 let c = "CAE3U7JKESRWZHPEQ72DVNGOQ6WPA7HSPQZL5YV46NPCE4TMUPAGYMEC";
453 let hex = contract_id_to_hex(c).expect("StrKey C... should decode");
454 assert_eq!(hex.len(), 64);
456 assert_eq!(contract_id_to_hex(&hex).unwrap(), hex);
458 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 assert!(
470 contract_id_to_hex("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF").is_err()
471 );
472 }
473}