use crate::client::SorobanRpcClient;
use crate::error::RpcError;
use crate::wasm::get_wasm_bytecode;
use sdkt_wasm::{parse_contract_spec, ContractSpec};
use sdkt_xdr::{
decode_contract_id, encode_ledger_key, extract_contract_data_value,
extract_contract_executable, parse_external_executable_hash, ContractExecutableRef,
ExternalExecutableRef, LedgerKeyParams,
};
use serde::Deserialize;
use serde::Serialize;
use stellar_xdr::{ContractDataDurability, ScAddress, ScVal};
pub(crate) fn contract_id_to_hex(contract_id: &str) -> Result<String, RpcError> {
if let Ok(bytes) = hex::decode(contract_id) {
if bytes.len() == 32 {
return Ok(contract_id.to_string());
}
}
let hash = decode_contract_id(contract_id)
.map_err(|e| RpcError::Rpc(format!("Invalid contract ID '{contract_id}': {e}")))?;
Ok(hex::encode(hash.0))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ContractAbiSummary {
pub functions: Vec<String>,
pub events: Vec<String>,
pub types: Vec<String>,
}
impl ContractAbiSummary {
pub fn from_spec(spec: &ContractSpec) -> Self {
Self {
functions: spec.functions.iter().map(|f| f.name.clone()).collect(),
events: spec.events.iter().map(|e| e.name.clone()).collect(),
types: spec.custom_types.iter().map(|t| t.name.clone()).collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ContractInspection {
pub contract_id: String,
pub wasm_hash: String,
pub wasm_size: Option<usize>,
pub abi: Option<ContractAbiSummary>,
pub storage_summary: StorageSummary,
pub ttl_info: Option<TtlInfoSummary>,
pub storage_keys: Vec<StorageKeyInfo>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct TtlInfoSummary {
pub minimum_ttl: u32,
pub maximum_ttl: u32,
pub average_ttl: u32,
pub expiring_entries_count: usize,
pub estimated_rent_cost: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct StorageSummary {
pub instance_entries: usize,
pub persistent_entries: usize,
pub temporary_entries: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StorageKeyInfo {
pub key: String,
pub key_type: String,
pub permissions: String,
}
pub async fn inspect_contract(
client: &SorobanRpcClient,
contract_id: &str,
) -> Result<ContractInspection, RpcError> {
let contract_id_hex = contract_id_to_hex(contract_id)?;
let encoded_key = encode_ledger_key(&LedgerKeyParams::ContractData(contract_id_hex.clone()))
.map_err(|e| RpcError::Rpc(format!("Failed to encode ledger key: {e}")))?;
let response = client
.get_contract_storage(contract_id, &[encoded_key])
.await?;
if response.entries.is_empty() {
return Err(RpcError::ContractNotFound);
}
let first_entry = &response.entries[0];
let wasm_hash = match resolve_inspection_wasm_hash(client, &first_entry.xdr).await {
Ok(hash) => hash,
Err(ExecutableResolutionError::Decode(e)) => {
return Err(RpcError::Rpc(format!("Failed to extract WASM hash: {e}")))
}
Err(e) => return Err(RpcError::Rpc(e.to_string())),
};
let mut wasm_size = None;
let mut abi = None;
if let Ok(bytes) = get_wasm_bytecode(client, &wasm_hash).await {
wasm_size = Some(bytes.len());
if let Ok(spec) = parse_contract_spec(&bytes) {
abi = Some(ContractAbiSummary::from_spec(&spec));
}
}
Ok(ContractInspection {
contract_id: contract_id.to_string(),
wasm_hash,
wasm_size,
abi,
storage_summary: StorageSummary::default(),
ttl_info: None,
storage_keys: Vec::new(),
})
}
#[derive(Debug, Clone, PartialEq)]
pub struct DeployedExecutable {
pub instance_found: bool,
pub executable: Option<sdkt_xdr::ContractExecutableRef>,
pub wasm_hash: Option<String>,
pub decode_error: Option<String>,
pub resolve_error: Option<String>,
pub error: Option<String>,
}
pub async fn probe_deployed_executable(
client: &SorobanRpcClient,
contract_id: &str,
) -> Result<DeployedExecutable, RpcError> {
let contract_id_hex = contract_id_to_hex(contract_id)?;
let encoded_key = encode_ledger_key(&LedgerKeyParams::ContractData(contract_id_hex))
.map_err(|e| RpcError::Rpc(format!("Failed to encode ledger key: {e}")))?;
let response = client.get_contract_storage("", &[encoded_key]).await?;
if response.entries.is_empty() {
return Ok(DeployedExecutable {
instance_found: false,
executable: None,
wasm_hash: None,
decode_error: None,
resolve_error: None,
error: None,
});
}
let entry = &response.entries[0].xdr;
let executable = match extract_contract_executable(entry) {
Ok(exec) => Some(exec),
Err(e) => {
return Ok(DeployedExecutable {
instance_found: true,
executable: None,
wasm_hash: None,
decode_error: Some(format!("{e}")),
resolve_error: None,
error: None,
})
}
};
let mut wasm_hash = None;
let mut resolve_error = None;
if let Some(sdkt_xdr::ContractExecutableRef::Wasm(hash)) = &executable {
wasm_hash = Some(hex::encode(hash.0));
} else if let Some(r) = executable.as_ref().and_then(|e| e.executable_tag_key()) {
match resolve_external_executable(client, r).await {
Ok(hash) => wasm_hash = Some(hash),
Err(e) => resolve_error = Some(format!("{e}")),
}
}
Ok(DeployedExecutable {
instance_found: true,
executable,
wasm_hash,
decode_error: None,
resolve_error,
error: None,
})
}
async fn resolve_inspection_wasm_hash(
client: &SorobanRpcClient,
entry_xdr: &str,
) -> Result<String, ExecutableResolutionError> {
let executable =
extract_contract_executable(entry_xdr).map_err(ExecutableResolutionError::Decode)?;
match executable {
ContractExecutableRef::Wasm(hash) => Ok(hex::encode(hash.0)),
ContractExecutableRef::StellarAsset => Err(ExecutableResolutionError::Unsupported {
kind: executable.kind(),
}),
ContractExecutableRef::ExternalRef(reference) => {
resolve_external_executable(client, &reference).await
}
}
}
#[derive(Debug)]
pub enum ExecutableResolutionError {
Decode(sdkt_xdr::DecodeError),
Unsupported {
kind: &'static str,
},
External {
owner: ScAddress,
tag: String,
source: String,
},
}
impl std::fmt::Display for ExecutableResolutionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ExecutableResolutionError::Decode(e) => write!(f, "{e}"),
ExecutableResolutionError::Unsupported { kind } => write!(
f,
"contract executable is `{kind}` (Protocol 28): it is not a Wasm contract \
and has no Wasm hash to report"
),
ExecutableResolutionError::External { owner, tag, source } => write!(
f,
"could not resolve the CAP-85 external executable reference (owner {owner}, \
tag \"{tag}\"): {source}"
),
}
}
}
pub async fn resolve_external_executable(
client: &SorobanRpcClient,
reference: &ExternalExecutableRef,
) -> Result<String, ExecutableResolutionError> {
let owner = reference.owner.to_string();
let tag = reference.tag.clone();
let key = executable_tag_key(&reference.owner, &reference.tag).map_err(|source| {
ExecutableResolutionError::External {
owner: reference.owner.clone(),
tag: tag.clone(),
source,
}
})?;
let response = client
.get_contract_storage(&owner, &[key])
.await
.map_err(|e| ExecutableResolutionError::External {
owner: reference.owner.clone(),
tag: tag.clone(),
source: e.to_string(),
})?;
let entry = response
.entries
.first()
.ok_or_else(|| ExecutableResolutionError::External {
owner: reference.owner.clone(),
tag: tag.clone(),
source: format!(
"owner {owner} has no executable tag entry named \"{tag}\" (it may never have \
been created, or the entry may have expired)"
),
})?;
let value = extract_contract_data_value(&entry.xdr).map_err(|e| {
ExecutableResolutionError::External {
owner: reference.owner.clone(),
tag: tag.clone(),
source: format!("could not decode the owner's executable tag entry: {e}"),
}
})?;
parse_external_executable_hash(&value.val).map_err(|e| ExecutableResolutionError::External {
owner: reference.owner.clone(),
tag: tag.clone(),
source: e.to_string(),
})
}
fn executable_tag_key(owner: &ScAddress, tag: &str) -> Result<String, String> {
if !matches!(owner, ScAddress::Contract(_)) {
return Err(format!(
"executable owner {owner} is not a contract address; only a contract can hold an \
executable tag entry"
));
}
let tag_bytes = stellar_xdr::StringM::try_from(tag.as_bytes().to_vec())
.map_err(|_| "executable tag exceeds the maximum ScString length".to_string())?;
encode_ledger_key(&LedgerKeyParams::ContractDataEntry {
contract: owner.to_string(),
key: ScVal::ExecutableTag(stellar_xdr::ScString(tag_bytes)),
durability: ContractDataDurability::Persistent,
})
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn abi_summary_from_spec_lists_names() {
let spec = ContractSpec {
env_meta: None,
functions: vec![sdkt_wasm::ContractFunction {
name: "transfer".into(),
doc: String::new(),
parameters: vec![],
outputs: vec![],
}],
custom_types: vec![sdkt_wasm::ContractType {
name: "Asset".into(),
kind: "Struct".into(),
doc: String::new(),
members: vec![],
type_args: vec![],
bytes_n: None,
}],
events: vec![sdkt_wasm::ContractEvent {
name: "transfer".into(),
doc: String::new(),
params: vec![],
prefix_topics: vec![],
data_format: "single_value".into(),
}],
};
let summary = ContractAbiSummary::from_spec(&spec);
assert_eq!(summary.functions, vec!["transfer".to_string()]);
assert_eq!(summary.events, vec!["transfer".to_string()]);
assert_eq!(summary.types, vec!["Asset".to_string()]);
}
#[test]
fn abi_summary_default_is_empty() {
let s = ContractAbiSummary::default();
assert!(s.functions.is_empty());
assert!(s.events.is_empty());
assert!(s.types.is_empty());
}
#[test]
fn contract_id_to_hex_accepts_strkey_c_address() {
let c = "CAE3U7JKESRWZHPEQ72DVNGOQ6WPA7HSPQZL5YV46NPCE4TMUPAGYMEC";
let hex = contract_id_to_hex(c).expect("StrKey C... should decode");
assert_eq!(hex.len(), 64);
assert_eq!(contract_id_to_hex(&hex).unwrap(), hex);
assert_eq!(
hex,
"09ba7d2a24a36c9de487f43ab4ce87acf07cf27c32bee2bcf35e22726ca3c06c"
);
}
#[test]
fn contract_id_to_hex_rejects_garbage() {
assert!(contract_id_to_hex("not-a-contract-id").is_err());
assert!(
contract_id_to_hex("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF").is_err()
);
}
}