Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 8 additions & 30 deletions crates/core/src/decode/contract_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,42 +20,20 @@ async fn resolve_with_network(
) -> GratResult<ContractErrorInfo> {
ContractId::new(contract_id)?;

let cache = crate::cache::store::CacheStore::default_location()?;
let cache_key = format!("{contract_id}_spec");
let resolver = crate::decode::contract_error_resolver::ContractErrorResolver::new(network.clone());
let (error_name, doc_comment) = resolver.resolve(contract_id, error_code).await;

let wasm_bytes = if let Some(cached) =
cache.get(crate::cache::store::CacheCategory::WasmBlob, &cache_key)?
{
cached
let error_name_opt = if error_name == error_code.to_string() {
None
} else {
let wasm = fetch_contract_wasm(contract_id, network).await?;
let _ = cache.put(
crate::cache::store::CacheCategory::WasmBlob,
&cache_key,
&wasm,
);
wasm
Some(error_name)
};

let spec = decoder::decode_contract_spec(&wasm_bytes)?;

let error_entry = decoder::resolve_error_code(&spec, error_code);

Ok(ContractErrorInfo {
contract_id: contract_id.to_string(),
error_code,
error_name: error_entry.map(|e| e.name.clone()),
doc_comment: error_entry.and_then(|e| e.doc.clone()),
error_name: error_name_opt,
doc_comment,
learn_more: "https://developers.stellar.org/docs/learn/smart-contracts/errors#contract-specific-errors".to_string(),
})
}

async fn fetch_contract_wasm(contract_id: &str, network: &NetworkConfig) -> GratResult<Vec<u8>> {
let rpc = crate::rpc::SorobanRpcClient::new(network);

let _result = rpc.get_ledger_entries(&[contract_id.to_string()]).await?;

Err(GratError::ContractNotFound(format!(
"WASM fetch not yet implemented for {contract_id}"
)))
})
}
216 changes: 216 additions & 0 deletions crates/core/src/decode/contract_error_resolver.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
use crate::error::{GratError, GratResult};
use crate::types::config::NetworkConfig;
use crate::types::address::Address;
use crate::spec::decoder::{decode_contract_spec, resolve_error_code};
use crate::cache::store::{CacheStore, CacheCategory};
use crate::rpc::SorobanRpcClient;
use crate::xdr::codec::XdrCodec;
use stellar_xdr::curr::{
LedgerEntry, LedgerEntryData, LedgerKey, LedgerKeyContractData, LedgerKeyContractCode,
ContractDataDurability, Hash, ScAddress, ScVal, ContractExecutable
};

pub struct ContractErrorResolver {
network: NetworkConfig,
}

impl ContractErrorResolver {
pub fn new(network: NetworkConfig) -> Self {
Self { network }
}

/// Resolves a numeric contract error code to its human-readable name and doc string.
/// Gracefully falls back to returning the raw integer as a string if resolution fails.
pub async fn resolve(&self, contract_id: &str, error_code: u32) -> (String, Option<String>) {
match self.resolve_inner(contract_id, error_code).await {
Ok((name, doc)) => (name, doc),
Err(e) => {
tracing::warn!(
contract_id,
error_code,
error = %e,
"Failed to resolve contract error code, falling back to raw integer"
);
(error_code.to_string(), None)
}
}
}

async fn resolve_inner(&self, contract_id: &str, error_code: u32) -> GratResult<(String, Option<String>)> {
// Validate Contract ID
Address::validate_contract_id(contract_id)?;

// Try local cache first
let cache = CacheStore::default_location()?;
let cache_key = format!("{contract_id}_spec");

let wasm_bytes = if let Some(cached) = cache.get(CacheCategory::WasmBlob, &cache_key)? {
cached
} else {
// Fetch WASM from the network
let fetched = self.fetch_wasm_from_network(contract_id).await?;
// Cache it
let _ = cache.put(CacheCategory::WasmBlob, &cache_key, &fetched);
fetched
};

// Parse WASM spec
let spec = decode_contract_spec(&wasm_bytes)?;

// Resolve error code
if let Some(entry) = resolve_error_code(&spec, error_code) {
Ok((entry.name.clone(), entry.doc.clone()))
} else {
Err(GratError::SpecError(format!(
"Error code {} not found in contract {} spec",
error_code, contract_id
)))
}
}

async fn fetch_wasm_from_network(&self, contract_id: &str) -> GratResult<Vec<u8>> {
let rpc_client = SorobanRpcClient::new(&self.network);
let address = Address::from_contract_id(contract_id)?;

let mut contract_bytes = [0u8; 32];
contract_bytes.copy_from_slice(&address.bytes);

// Step 1: Fetch ContractData LedgerEntry to find the WASM hash
let instance_key = LedgerKey::ContractData(LedgerKeyContractData {
contract: ScAddress::Contract(Hash(contract_bytes)),
key: ScVal::LedgerKeyContractInstance,
durability: ContractDataDurability::Persistent,
});

let base64_key = instance_key.to_xdr_base64()?;
let response = rpc_client.get_ledger_entries(&[base64_key]).await?;

let entries = response
.get("entries")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| {
GratError::ContractNotFound(format!("Invalid response for contract {contract_id}"))
})?;

if entries.is_empty() {
return Err(GratError::ContractNotFound(contract_id.to_string()));
}

let entry_val = &entries[0];
let xdr_base64 = entry_val
.get("xdr")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
GratError::SpecError(format!(
"Missing XDR in response for contract {contract_id}"
))
})?;

let ledger_entry = LedgerEntry::from_xdr_base64(xdr_base64)?;

let wasm_hash = match ledger_entry.data {
LedgerEntryData::ContractData(contract_data) => {
match contract_data.val {
ScVal::ContractInstance(instance) => {
match instance.executable {
ContractExecutable::Wasm(wasm_hash) => wasm_hash,
_ => return Err(GratError::SpecError("Contract is not a WASM executable".to_string())),
}
}
_ => return Err(GratError::SpecError("Ledger entry value is not a ContractInstance".to_string())),
}
}
_ => return Err(GratError::SpecError("Ledger entry is not ContractData".to_string())),
};

// Step 2: Fetch the ContractCode LedgerEntry using wasm_hash
let code_key = LedgerKey::ContractCode(LedgerKeyContractCode {
hash: wasm_hash,
});

let base64_code_key = code_key.to_xdr_base64()?;
let code_response = rpc_client.get_ledger_entries(&[base64_code_key]).await?;

let code_entries = code_response
.get("entries")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| {
GratError::ContractNotFound(format!("Invalid response for contract code {contract_id}"))
})?;

if code_entries.is_empty() {
return Err(GratError::ContractNotFound(contract_id.to_string()));
}

let code_entry_val = &code_entries[0];
let code_xdr_base64 = code_entry_val
.get("xdr")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
GratError::SpecError(format!("Missing XDR in response for contract code {contract_id}"))
})?;

let code_ledger_entry = LedgerEntry::from_xdr_base64(code_xdr_base64)?;

match code_ledger_entry.data {
LedgerEntryData::ContractCode(code_entry) => Ok(code_entry.code.to_vec()),
_ => Err(GratError::SpecError(
"Ledger entry is not a contract code entry".to_string(),
)),
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use stellar_xdr::curr::{ScSpecEntry, ScSpecUdtErrorEnumV0, ScSpecUdtErrorEnumCaseV0, WriteXdr};

#[tokio::test]
async fn test_resolve_from_cache_success() {
// Construct a mock error enum spec entry
let error_enum = ScSpecUdtErrorEnumV0 {
doc: "Error documentation".try_into().unwrap(),
name: "MyError".try_into().unwrap(),
cases: vec![ScSpecUdtErrorEnumCaseV0 {
doc: "Some doc".try_into().unwrap(),
name: "Failed".try_into().unwrap(),
value: 1042,
}].try_into().unwrap(),
};
let entry = ScSpecEntry::UdtErrorEnumV0(error_enum);
let mut entry_bytes = Vec::new();
entry.write_xdr(&mut entry_bytes).unwrap();

// Construct mock WASM payload
let mut wasm = vec![0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00];
let section_name = "contractspecv0";
let section_data = entry_bytes;

let mut custom_payload = Vec::new();
custom_payload.push(section_name.len() as u8);
custom_payload.extend_from_slice(section_name.as_bytes());
custom_payload.extend_from_slice(&section_data);

wasm.push(0);
wasm.push(custom_payload.len() as u8);
wasm.extend(custom_payload);

let contract_id = "CA3D5KTHBN6TOVTTTA74S4O4O4O4O4O4O4O4O4O4O4O4O4O4O4O4O4O4";
let cache = CacheStore::default_location().unwrap();
let cache_key = format!("{contract_id}_spec");
cache.put(CacheCategory::WasmBlob, &cache_key, &wasm).unwrap();

let resolver = ContractErrorResolver::new(NetworkConfig::testnet());
let (name, doc) = resolver.resolve(contract_id, 1042).await;
assert_eq!(name, "MyError::Failed");
assert_eq!(doc, Some("Some doc".to_string()));

let (name_fb, doc_fb) = resolver.resolve(contract_id, 9999).await;
assert_eq!(name_fb, "9999");
assert_eq!(doc_fb, None);

// Cleanup cache
let _ = cache.remove(CacheCategory::WasmBlob, &cache_key);
}
}
1 change: 1 addition & 0 deletions crates/core/src/decode/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub mod auth_address_nonce;
pub mod auth_signature;
pub mod context;
pub mod contract_error;
pub mod contract_error_resolver;
pub mod cross_contract;
pub mod decode_context;
pub mod diagnostic;
Expand Down
Loading