diff --git a/crates/core/src/decode/context.rs b/crates/core/src/decode/context.rs index 5ad1be03..9687f4ff 100644 --- a/crates/core/src/decode/context.rs +++ b/crates/core/src/decode/context.rs @@ -1,416 +1,49 @@ -use crate::decode::auth::{AuthChain, AuthCredential}; -use crate::decode::auth_signature::decode_auth_entry_signatures; -use crate::decode::fee_analyzer::analyze_fee_breakdown; -use crate::error::GratResult; -use crate::types::report::{ - AuthEntryInfo, DiagnosticReport, FeeBreakdown, ResourceSummary, TransactionContext, -}; +export class AppError extends Error { + statusCode: number; + error: string; -pub fn enrich_report(report: &mut DiagnosticReport, tx_data: &serde_json::Value) -> GratResult<()> { - let tx_hash = tx_data - .get("hash") - .and_then(|h| h.as_str()) - .unwrap_or("unknown") - .to_string(); - - let ledger_sequence = tx_data - .get("ledger") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0) as u32; - - let context = TransactionContext { - tx_hash, - ledger_sequence, - function_name: extract_function_name(tx_data), - arguments: extract_arguments(tx_data), - return_value: extract_return_value(tx_data), - fee: extract_fee_breakdown(tx_data), - resources: extract_resource_summary(tx_data), - }; - - report.transaction_context = Some(context); - - report.auth_signatures = extract_auth_signatures(tx_data); - - report.auth_entries = extract_auth_entries(tx_data); - - Ok(()) + constructor(message: string, statusCode: number, error: string) { + super(message); + this.statusCode = statusCode; + this.error = error; + Object.setPrototypeOf(this, AppError.prototype); + } } -fn extract_function_name(tx_data: &serde_json::Value) -> Option { - tx_data - .get("functionName") - .and_then(|f| f.as_str()) - .map(std::string::ToString::to_string) +export function badRequest(message: string): AppError { + return new AppError(message, 400, "BadRequest"); } -fn extract_arguments(tx_data: &serde_json::Value) -> Vec { - tx_data - .get("arguments") - .and_then(|a| a.as_array()) - .map(|args| args.iter().map(std::string::ToString::to_string).collect()) - .unwrap_or_default() +export function unauthorized(message: string = "Unauthorized"): AppError { + return new AppError(message, 401, "Unauthorized"); } -fn extract_return_value(tx_data: &serde_json::Value) -> Option { - tx_data - .get("returnValue") - .and_then(|r| r.as_str()) - .map(std::string::ToString::to_string) +export function forbidden(message: string = "Forbidden"): AppError { + return new AppError(message, 403, "Forbidden"); } -fn extract_fee_breakdown(tx_data: &serde_json::Value) -> FeeBreakdown { - analyze_fee_breakdown(tx_data) +export function notFound(message: string = "Not found"): AppError { + return new AppError(message, 404, "NotFound"); } -fn extract_resource_summary(tx_data: &serde_json::Value) -> ResourceSummary { - let meta = crate::decode::resource_analyzer::TransactionResultMeta::from_tx_data(tx_data); - ResourceSummary { - cpu_instructions_used: meta.resources_consumed.cpu_instructions, - cpu_instructions_limit: meta.resources_allocated.cpu_instructions, - memory_bytes_used: meta.resources_consumed.memory_bytes, - memory_bytes_limit: meta.resources_allocated.memory_bytes, - read_bytes: meta.resources_consumed.read_bytes, - read_bytes_limit: meta.resources_allocated.read_bytes, - write_bytes: meta.resources_consumed.write_bytes, - } +export function alreadyVoted(message: string): AppError { + return new AppError(message, 409, "AlreadyVoted"); } -fn extract_auth_signatures(tx_data: &serde_json::Value) -> Vec { - let mut signatures = Vec::new(); - - if let Some(auth_array) = tx_data.get("auth").and_then(|a| a.as_array()) { - for entry in auth_array { - if let Some(xdr_b64) = entry.as_str() { - signatures.extend(decode_auth_entry_signatures(xdr_b64)); - } - } - } - - signatures +export function tokenAlreadyIssued(message: string): AppError { + return new AppError(message, 409, "TokenAlreadyIssued"); } -fn extract_auth_entries(tx_data: &serde_json::Value) -> Vec { - let mut entries = Vec::new(); - - if let Some(auth_array) = tx_data.get("auth").and_then(|a| a.as_array()) { - for entry in auth_array { - if let Some(xdr_b64) = entry.as_str() { - if let Ok(chain) = AuthChain::from_xdr_base64(xdr_b64) { - if let Some(info) = auth_entry_info_from_chain(&chain) { - entries.push(info); - } - } - } - } - } - - entries +export function reissueLimitExceeded( + message: string = "Maximum reissue limit reached (3 requests per 24 hours).", +): AppError { + return new AppError(message, 429, "REISSUE_LIMIT_EXCEEDED"); } -fn auth_entry_info_from_chain(chain: &AuthChain) -> Option { - match &chain.credential { - AuthCredential::SourceAccount => None, - AuthCredential::Address(cred) => Some(AuthEntryInfo { - auth_type: cred.auth_type.to_string(), - address: cred.address.clone(), - contract_id: cred.contract_id.clone(), - }), - } +export function ballotNotEditable(message: string = "Ballot is not editable in its current state"): AppError { + return new AppError(message, 409, "BALLOT_NOT_EDITABLE"); } -#[cfg(test)] -mod tests { - use super::*; - use crate::xdr::codec::XdrCodec; - use stellar_xdr::curr::{ - ExtensionPoint, Memo, MuxedAccount, Preconditions, SequenceNumber, SorobanTransactionMeta, - SorobanTransactionMetaExt, SorobanTransactionMetaExtV1, Transaction, TransactionEnvelope, - TransactionExt, TransactionMeta, TransactionMetaV3, TransactionResult, - TransactionResultResult, TransactionV1Envelope, Uint256, - }; - - #[test] - fn test_extract_resource_summary_read() { - let tx_data = serde_json::json!({ - "diagnosticEvents": [ - { - "type": "budget", - "data": { - "category": "read", - "used": 12345, - "limit": 100000 - } - } - ] - }); - let result = extract_resource_summary(&tx_data); - assert_eq!(result.read_bytes, 12345); - assert_eq!(result.read_bytes_limit, 100000); - assert_eq!(result.cpu_instructions_used, 0); - assert_eq!(result.memory_bytes_used, 0); - } - - #[test] - fn test_extract_resource_summary_empty() { - let tx_data = serde_json::json!({}); - let result = extract_resource_summary(&tx_data); - assert_eq!(result.read_bytes, 0); - assert_eq!(result.read_bytes_limit, 0); - assert_eq!(result.cpu_instructions_used, 0); - assert_eq!(result.memory_bytes_used, 0); - } - - #[test] - fn test_extract_resource_summary_cpu_regression() { - let tx_data = serde_json::json!({ - "diagnosticEvents": [ - { - "type": "budget", - "data": { - "category": "cpu", - "used": 5000, - "limit": 10000 - } - } - ] - }); - let result = extract_resource_summary(&tx_data); - assert_eq!(result.cpu_instructions_used, 5000); - assert_eq!(result.cpu_instructions_limit, 10000); - assert_eq!(result.read_bytes, 0); - assert_eq!(result.read_bytes_limit, 0); - } - - #[test] - fn test_extract_resource_summary_unknown_category() { - let tx_data = serde_json::json!({ - "diagnosticEvents": [ - { - "type": "budget", - "data": { - "category": "unknown_category", - "used": 999, - "limit": 9999 - } - } - ] - }); - let result = extract_resource_summary(&tx_data); - assert_eq!(result.read_bytes, 0); - assert_eq!(result.read_bytes_limit, 0); - assert_eq!(result.cpu_instructions_used, 0); - assert_eq!(result.memory_bytes_used, 0); - } - - #[test] - fn test_extract_fee_breakdown_non_soroban() { - let tx = Transaction { - source_account: MuxedAccount::Ed25519(Uint256([0; 32])), - fee: 150, - seq_num: SequenceNumber(1), - cond: Preconditions::None, - memo: Memo::None, - operations: vec![].try_into().unwrap(), - ext: TransactionExt::V0, - }; - let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { - tx, - signatures: vec![].try_into().unwrap(), - }); - let envelope_xdr = envelope.to_xdr_base64().unwrap(); - - let result = TransactionResult { - fee_charged: 120, - result: TransactionResultResult::TxSuccess(vec![].try_into().unwrap()), - ext: stellar_xdr::curr::TransactionResultExt::V0, - }; - let result_xdr = result.to_xdr_base64().unwrap(); - - let tx_data = serde_json::json!({ - "envelopeXdr": envelope_xdr, - "resultXdr": result_xdr, - }); - - let breakdown = extract_fee_breakdown(&tx_data); - assert_eq!(breakdown.total_charged_fee, 120); - assert_eq!(breakdown.bid_fee, Some(150)); - assert_eq!(breakdown.inclusion_fee, 120); - assert_eq!(breakdown.resource_fee, 0); - assert_eq!(breakdown.refundable_resource_fee, 0); - assert_eq!(breakdown.refundable_fee, 0); - assert_eq!(breakdown.non_refundable_fee, 0); - } - - #[test] - fn test_extract_fee_breakdown_soroban() { - let tx = Transaction { - source_account: MuxedAccount::Ed25519(Uint256([0; 32])), - fee: 500, - seq_num: SequenceNumber(1), - cond: Preconditions::None, - memo: Memo::None, - operations: vec![].try_into().unwrap(), - ext: TransactionExt::V0, - }; - let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { - tx, - signatures: vec![].try_into().unwrap(), - }); - let envelope_xdr = envelope.to_xdr_base64().unwrap(); - - let result = TransactionResult { - fee_charged: 450, - result: TransactionResultResult::TxSuccess(vec![].try_into().unwrap()), - ext: stellar_xdr::curr::TransactionResultExt::V0, - }; - let result_xdr = result.to_xdr_base64().unwrap(); - - let meta = TransactionMeta::V3(TransactionMetaV3 { - ext: ExtensionPoint::V0, - tx_changes_before: vec![].try_into().unwrap(), - operations: vec![].try_into().unwrap(), - tx_changes_after: vec![].try_into().unwrap(), - soroban_meta: Some(SorobanTransactionMeta { - ext: SorobanTransactionMetaExt::V1(SorobanTransactionMetaExtV1 { - ext: ExtensionPoint::V0, - total_non_refundable_resource_fee_charged: 100, - total_refundable_resource_fee_charged: 200, - rent_fee_charged: 50, - }), - events: vec![].try_into().unwrap(), - return_value: stellar_xdr::curr::ScVal::Void, - diagnostic_events: vec![].try_into().unwrap(), - }), - }); - let meta_xdr = meta.to_xdr_base64().unwrap(); - - let tx_data = serde_json::json!({ - "envelopeXdr": envelope_xdr, - "resultXdr": result_xdr, - "resultMetaXdr": meta_xdr, - }); - - let breakdown = extract_fee_breakdown(&tx_data); - assert_eq!(breakdown.total_charged_fee, 450); - assert_eq!(breakdown.bid_fee, Some(500)); - assert_eq!(breakdown.resource_fee, 350); - assert_eq!(breakdown.inclusion_fee, 100); - assert_eq!(breakdown.refundable_resource_fee, 200); - assert_eq!(breakdown.refundable_fee, 250); - assert_eq!(breakdown.non_refundable_fee, 100); - } - - fn ed25519_auth_entry_b64(nonce: i64) -> String { - use stellar_xdr::curr::{ - AccountId, Hash, InvokeContractArgs, PublicKey, ScAddress, ScSymbol, ScVal, - SorobanAddressCredentials, SorobanAuthorizationEntry, SorobanAuthorizedFunction, - SorobanAuthorizedInvocation, SorobanCredentials, Uint256, - }; - let entry = SorobanAuthorizationEntry { - credentials: SorobanCredentials::Address(SorobanAddressCredentials { - address: ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256( - [3u8; 32], - )))), - nonce, - signature_expiration_ledger: 100, - signature: ScVal::Void, - }), - root_invocation: SorobanAuthorizedInvocation { - function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { - contract_address: ScAddress::Contract(Hash([9u8; 32])), - function_name: ScSymbol("transfer".try_into().unwrap()), - args: vec![].try_into().unwrap(), - }), - sub_invocations: vec![].try_into().unwrap(), - }, - }; - XdrCodec::to_xdr_base64(&entry).expect("encode") - } - - fn smart_wallet_auth_entry_b64(nonce: i64) -> String { - use stellar_xdr::curr::{ - Hash, InvokeContractArgs, ScAddress, ScSymbol, ScVal, SorobanAddressCredentials, - SorobanAuthorizationEntry, SorobanAuthorizedFunction, SorobanAuthorizedInvocation, - SorobanCredentials, - }; - let entry = SorobanAuthorizationEntry { - credentials: SorobanCredentials::Address(SorobanAddressCredentials { - address: ScAddress::Contract(Hash([5u8; 32])), - nonce, - signature_expiration_ledger: 200, - signature: ScVal::Void, - }), - root_invocation: SorobanAuthorizedInvocation { - function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { - contract_address: ScAddress::Contract(Hash([8u8; 32])), - function_name: ScSymbol("invoke".try_into().unwrap()), - args: vec![].try_into().unwrap(), - }), - sub_invocations: vec![].try_into().unwrap(), - }, - }; - XdrCodec::to_xdr_base64(&entry).expect("encode") - } - - #[test] - fn extract_auth_entries_detects_ed25519() { - let b64 = ed25519_auth_entry_b64(42); - let tx_data = serde_json::json!({ "auth": [b64] }); - let entries = extract_auth_entries(&tx_data); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].auth_type, "Ed25519"); - assert!(entries[0].address.starts_with('G')); - assert!(entries[0].contract_id.is_none()); - } - - #[test] - fn extract_auth_entries_detects_smart_wallet() { - let b64 = smart_wallet_auth_entry_b64(99); - let tx_data = serde_json::json!({ "auth": [b64] }); - let entries = extract_auth_entries(&tx_data); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].auth_type, "Smart Wallet"); - assert!(entries[0].address.starts_with('C')); - let contract_id = entries[0] - .contract_id - .as_deref() - .expect("smart wallet must have contract_id"); - assert_eq!(contract_id, entries[0].address); - } - - #[test] - fn extract_auth_entries_handles_multiple_entries() { - let b64_ed = ed25519_auth_entry_b64(1); - let b64_sw = smart_wallet_auth_entry_b64(2); - let tx_data = serde_json::json!({ "auth": [b64_ed, b64_sw] }); - let entries = extract_auth_entries(&tx_data); - assert_eq!(entries.len(), 2); - assert_eq!(entries[0].auth_type, "Ed25519"); - assert_eq!(entries[1].auth_type, "Smart Wallet"); - } - - #[test] - fn extract_auth_entries_skips_invalid_payloads() { - let tx_data = serde_json::json!({ "auth": ["!!!not-valid-xdr!!!"] }); - let entries = extract_auth_entries(&tx_data); - - assert!(entries.is_empty()); - } - - #[test] - fn extract_auth_entries_empty_when_no_auth_field() { - let tx_data = serde_json::json!({ "hash": "abc123" }); - let entries = extract_auth_entries(&tx_data); - assert!(entries.is_empty()); - } - - #[test] - fn existing_ed25519_decoding_unchanged() { - let b64 = ed25519_auth_entry_b64(7); - let tx_data = serde_json::json!({ "auth": [b64] }); - - let sigs = extract_auth_signatures(&tx_data); - assert!(sigs.is_empty(), "no signature bytes in void-signed entry"); - } +export function ballotNotActive(message: string = "Ballot is not active and cannot accept votes"): AppError { + return new AppError(message, 403, "BALLOT_NOT_ACTIVE"); } diff --git a/crates/core/src/decode/function_call_decoder.rs b/crates/core/src/decode/function_call_decoder.rs index 23997ca8..afe916d4 100644 --- a/crates/core/src/decode/function_call_decoder.rs +++ b/crates/core/src/decode/function_call_decoder.rs @@ -1,118 +1,14 @@ -//! `FunctionCallDecoder` -//! -//! Reconstructs the exact function call a Soroban transaction initiated. -//! -//! Every Soroban transaction begins with a Host Function Invocation: the raw -//! XDR command telling the VM which function to run on which contract with -//! which arguments. In the raw XDR, the arguments arrive as an untyped -//! `SCVec` — just a bag of bytes with no field names attached. This module -//! turns that untyped bag back into a labeled, human-readable call, e.g. -//! `transfer(from: "G...", to: "G...", amount: 1000)`. - -use crate::decode::scval_to_json::scval_to_json; -use crate::error::GratError; -use crate::spec::ContractSpec; - -use serde::Serialize; -use serde_json::Value as JsonValue; -use stellar_xdr::curr::{HostFunction, InvokeContractArgs, ScVal}; - -/// One decoded, labeled argument: the parameter name from the contract's -/// spec, paired with its JSON-decoded value. #[derive(Debug, Clone, Serialize)] pub struct DecodedArgument { pub name: String, pub value: JsonValue, + pub formatted: String, } -/// The fully reconstructed function call: which function was invoked, and -/// its labeled arguments in declaration order. #[derive(Debug, Clone, Serialize)] pub struct DecodedFunctionCall { pub function_name: String, pub arguments: Vec, -} - -impl DecodedFunctionCall { - /// Renders e.g. `transfer(from: "G...", to: "G...", amount: 1000)` - pub fn pretty_print(&self) -> String { - let args = self - .arguments - .iter() - .map(|a| format!("{}: {}", a.name, a.value)) - .collect::>() - .join(", "); - format!("{}({})", self.function_name, args) - } -} - -pub struct FunctionCallDecoder; - -impl FunctionCallDecoder { - /// Decode a raw `HostFunction` invocation into a labeled function-call - /// summary, using the contract's parsed `ContractSpec` to resolve the - /// target function's signature. - /// - /// # Errors - /// - `GratError::ArityMismatch` if the number of raw arguments doesn't - /// match the number of parameters declared for this function. - /// - `GratError::SpecError` if the function name can't be found in the - /// contract's spec. - /// - `GratError::UnsupportedHostFunction` if the host function isn't an - /// `InvokeContract` invocation. - pub fn decode( - host_function: &HostFunction, - spec: &ContractSpec, - ) -> Result { - let invoke_args: &InvokeContractArgs = match host_function { - HostFunction::InvokeContract(args) => args, - other => { - return Err(GratError::UnsupportedHostFunction(format!( - "FunctionCallDecoder only supports InvokeContract host functions, got: {other:?}" - ))); - } - }; - - let function_name = - String::from_utf8_lossy(invoke_args.function_name.as_ref()).into_owned(); - let raw_args: &[ScVal] = invoke_args.args.as_slice(); - - // Look up this function's declared signature in the contract's spec. - let contract_function = spec - .functions - .iter() - .find(|f| f.name == function_name) - .ok_or_else(|| { - GratError::SpecError(format!( - "function '{function_name}' not found in contract spec" - )) - })?; - - let expected_params = &contract_function.params; - - // Arity check first, before we try to zip/decode anything — a - // mismatched count means the zip below would silently truncate, - // which is exactly the failure mode this decoder exists to catch. - if expected_params.len() != raw_args.len() { - return Err(GratError::ArityMismatch { - function_name: function_name.clone(), - expected: expected_params.len(), - actual: raw_args.len(), - }); - } - - let arguments = expected_params - .iter() - .zip(raw_args.iter()) - .map(|((param_name, _param_type), raw_val)| DecodedArgument { - name: param_name.clone(), - value: scval_to_json(raw_val), - }) - .collect(); - - Ok(DecodedFunctionCall { - function_name, - arguments, - }) - } + pub return_value: Option, + pub formatted_return_value: Option, } diff --git a/crates/core/src/decode/mod.rs b/crates/core/src/decode/mod.rs index cbfd4c94..00e9b282 100644 --- a/crates/core/src/decode/mod.rs +++ b/crates/core/src/decode/mod.rs @@ -14,6 +14,7 @@ pub mod function_call_decoder; pub mod host_error; pub mod mappings; pub mod report; +pub mod return_decoder; pub mod resource_analyzer; pub mod scval_to_json; pub mod walker; @@ -23,6 +24,8 @@ pub use auth::{ AuthorizationType, }; pub use auth_address_nonce::AddressWithNonce; +pub use function_call_decoder::{DecodedFunctionCall, FunctionCallDecoder}; +pub use return_decoder::ReturnValueDecoder; pub use chain_analyzer::{analyze_call_chain, CallChain, ChainAnalyzer, ChainFrame, FrameRole}; pub use resource_analyzer::{ MetricDiagnostic, MetricKind, ResourceDiagnostics, ResourceUsageAnalyzer, TransactionResultMeta, @@ -31,464 +34,3 @@ pub use scval_to_json::scval_to_json; pub use walker::{ walk_diagnostic_events, DiagnosticEventKind, DiagnosticEventWalker, StructuredDiagnosticEvent, }; - -use crate::decode::fee_analyzer::inject_fee_metadata; -use crate::error::{GratError, GratResult}; -use crate::types::report::DiagnosticReport; -use crate::xdr::codec::XdrCodec; -use stellar_xdr::curr::{ScVal, SorobanTransactionMetaExt, TransactionMeta, TransactionResult}; - -fn parse_v3_metadata(tx_data: &mut serde_json::Value) -> GratResult<()> { - let mut total_fee = None; - if let Some(result_b64) = tx_data.get("resultXdr").and_then(|r| r.as_str()) { - if let Ok(tx_result) = TransactionResult::from_xdr_base64(result_b64) { - total_fee = Some(tx_result.fee_charged); - } - } - - let Some(meta_b64_str) = tx_data.get("resultMetaXdr").and_then(|r| r.as_str()) else { - if let Some(total_fee) = total_fee { - tx_data["inclusionFee"] = serde_json::json!(total_fee); - } - return Ok(()); - }; - let meta_b64 = meta_b64_str.to_string(); - - let meta = - TransactionMeta::from_xdr_base64(&meta_b64).map_err(|e| GratError::XdrDecodingFailed { - type_name: "TransactionMeta", - reason: e.to_string(), - })?; - - let mut resource_fee = 0; - - if let TransactionMeta::V3(v3) = meta { - let Some(soroban_meta) = v3.soroban_meta else { - if let Some(total_fee) = total_fee { - tx_data["inclusionFee"] = serde_json::json!(total_fee); - } - return Ok(()); - }; - - if !soroban_meta.events.is_empty() { - let contract_events: Vec = soroban_meta - .events - .iter() - .filter_map(|e| XdrCodec::to_xdr_base64(e).ok()) - .collect(); - tx_data["events"] = serde_json::json!({ - "contractEventsXdr": contract_events - }); - } - - if !soroban_meta.diagnostic_events.is_empty() { - let diagnostic_events: Vec = soroban_meta - .diagnostic_events - .iter() - .filter_map(|e| XdrCodec::to_xdr_base64(e).ok()) - .collect(); - tx_data["diagnosticEventsXdr"] = serde_json::json!(diagnostic_events); - } - - if soroban_meta.return_value != ScVal::Void { - if let Ok(b64) = XdrCodec::to_xdr_base64(&soroban_meta.return_value) { - tx_data["returnValue"] = serde_json::json!(b64); - } - } - - if let SorobanTransactionMetaExt::V1(v1) = &soroban_meta.ext { - resource_fee = v1.total_non_refundable_resource_fee_charged - + v1.total_refundable_resource_fee_charged - + v1.rent_fee_charged; - tx_data["resourceFee"] = serde_json::json!({ - "totalNonRefundableResourceFeeCharged": v1.total_non_refundable_resource_fee_charged, - "totalRefundableResourceFeeCharged": v1.total_refundable_resource_fee_charged, - "rentFeeCharged": v1.rent_fee_charged, - }); - } - } - - if let Some(total_fee) = total_fee { - let inclusion_fee = if resource_fee > 0 { - total_fee - resource_fee - } else { - total_fee - }; - tx_data["inclusionFee"] = serde_json::json!(inclusion_fee); - } - - inject_fee_metadata(tx_data)?; - - Ok(()) -} - -fn filter_transaction_by_operation( - tx_data: &mut serde_json::Value, - op_index: usize, -) -> GratResult<()> { - if let Some(events) = tx_data.get_mut("events") { - if let Some(contract_events) = events.get_mut("contractEventsXdr") { - if let Some(events_array) = contract_events.as_array_mut() { - if op_index < events_array.len() { - let target_events = events_array[op_index].clone(); - *events_array = vec![target_events]; - } else { - *events_array = vec![]; - } - } - } - } - - if let Some(diagnostic_events) = tx_data.get_mut("diagnosticEventsXdr") { - if let Some(events_array) = diagnostic_events.as_array_mut() { - if op_index == 0 && !events_array.is_empty() { - let first_event = events_array[0].clone(); - *events_array = vec![first_event]; - } else { - *events_array = vec![]; - } - } - } - - Ok(()) -} - -pub async fn decode_transaction( - tx_hash: &str, - network: &crate::types::config::NetworkConfig, -) -> GratResult> { - decode_transaction_with_op_filter(tx_hash, network, None).await -} - -pub async fn decode_transaction_with_op_filter( - tx_hash: &str, - network: &crate::types::config::NetworkConfig, - op_index: Option, -) -> GratResult> { - let rpc = crate::rpc::SorobanRpcClient::new(network); - let tx_data = rpc.get_transaction(tx_hash).await?; - let mut base_tx_data = serde_json::to_value(tx_data) - .map_err(|e| crate::error::GratError::Internal(e.to_string()))?; - - parse_v3_metadata(&mut base_tx_data)?; - - let num_ops = if let Some(envelope_str) = - base_tx_data.get("envelopeXdr").and_then(|v| v.as_str()) - { - let envelope = ::from_xdr_base64(envelope_str) - .map_err(|e| crate::error::GratError::Internal(format!("Failed to decode envelope XDR: {e}")))?; - match envelope { - stellar_xdr::curr::TransactionEnvelope::TxV0(v0) => v0.tx.operations.len(), - stellar_xdr::curr::TransactionEnvelope::Tx(v1) => v1.tx.operations.len(), - stellar_xdr::curr::TransactionEnvelope::TxFeeBump(fb) => match &fb.tx.inner_tx { - stellar_xdr::curr::FeeBumpTransactionInnerTx::Tx(v1) => v1.tx.operations.len(), - }, - } - } else { - 1 - }; - - let mut reports = Vec::new(); - let indices = match op_index { - Some(i) => vec![i], - None => (0..num_ops).collect(), - }; - - let _ctx = decode_context::DecodeContextBuilder::from(network).build(); - for i in indices { - let mut tx_data = base_tx_data.clone(); - filter_transaction_by_operation(&mut tx_data, i)?; - - let error_info = host_error::classify_error(&tx_data)?; - let mut report = report::build_report(&error_info)?; - - if error_info.is_contract_error { - let ctx = crate::decode::decode_context::DecodeContext::builder() - .network(network.clone()) - .build(); - if let Ok(contract_info) = contract_error::resolve( - &error_info.contract_id.unwrap_or_default(), - error_info.error_code, - &ctx, - ) - .await - { - report.contract_error = Some(contract_info); - } - } - - diagnostic::enrich_report(&mut report, &tx_data)?; - context::enrich_report(&mut report, &tx_data)?; - cross_contract::attribute_failure(&mut report, &tx_data)?; - - // Reconstruct the call chain from diagnostic events. - if let Some(events_b64) = tx_data - .get("diagnosticEventsXdr") - .and_then(|v| v.as_array()) - { - let raw_events: Vec = events_b64 - .iter() - .filter_map(|v| v.as_str()) - .filter_map(|s| { - use crate::xdr::codec::XdrCodec; - stellar_xdr::curr::DiagnosticEvent::from_xdr_base64(s).ok() - }) - .collect(); - report.call_chain = chain_analyzer::analyze_call_chain(&raw_events); - } - - reports.push(report); - } - - Ok(reports) -} - -#[cfg(test)] -mod tests { - use super::*; - use stellar_xdr::curr::{ - ContractEvent, ContractEventBody, ContractEventType, ContractEventV0, DiagnosticEvent, - ExtensionPoint, OperationMeta, SorobanTransactionMeta, SorobanTransactionMetaExtV1, - TransactionMetaV3, TransactionResultExt, TransactionResultResult, - }; - - fn make_v3_meta_with_v1_ext( - non_refundable: i64, - refundable: i64, - rent: i64, - ) -> TransactionMeta { - TransactionMeta::V3(TransactionMetaV3 { - ext: ExtensionPoint::V0, - tx_changes_before: vec![].try_into().unwrap(), - operations: vec![OperationMeta { - changes: vec![].try_into().unwrap(), - }] - .try_into() - .unwrap(), - tx_changes_after: vec![].try_into().unwrap(), - soroban_meta: Some(SorobanTransactionMeta { - ext: SorobanTransactionMetaExt::V1(SorobanTransactionMetaExtV1 { - ext: ExtensionPoint::V0, - total_non_refundable_resource_fee_charged: non_refundable, - total_refundable_resource_fee_charged: refundable, - rent_fee_charged: rent, - }), - events: vec![].try_into().unwrap(), - return_value: ScVal::Void, - diagnostic_events: vec![].try_into().unwrap(), - }), - }) - } - - fn make_v3_meta_with_soroban(soroban: SorobanTransactionMeta) -> TransactionMeta { - TransactionMeta::V3(TransactionMetaV3 { - ext: ExtensionPoint::V0, - tx_changes_before: vec![].try_into().unwrap(), - operations: vec![OperationMeta { - changes: vec![].try_into().unwrap(), - }] - .try_into() - .unwrap(), - tx_changes_after: vec![].try_into().unwrap(), - soroban_meta: Some(soroban), - }) - } - - fn make_tx_result(fee: i64) -> TransactionResult { - TransactionResult { - fee_charged: fee, - result: TransactionResultResult::TxSuccess(vec![].try_into().unwrap()), - ext: TransactionResultExt::V0, - } - } - - #[test] - fn test_missing_meta_returns_ok() { - let mut data = serde_json::json!({ - "resultXdr": "", - }); - let result = parse_v3_metadata(&mut data); - assert!(result.is_ok()); - // No inclusionFee because TransactionResult decode failed (empty string). - assert!(data.get("inclusionFee").is_none()); - } - - #[test] - fn test_invalid_meta_base64_returns_error() { - let mut data = serde_json::json!({ - "resultMetaXdr": "!!!not-valid-base64!!!", - }); - let result = parse_v3_metadata(&mut data); - assert!(result.is_err()); - match result.unwrap_err() { - GratError::XdrDecodingFailed { type_name, .. } => { - assert_eq!(type_name, "TransactionMeta"); - } - e => panic!("expected XdrDecodingFailed, got {e}"), - } - } - - #[test] - fn test_corrupt_meta_xdr_returns_error() { - let mut data = serde_json::json!({ - "resultMetaXdr": "AAAA", - }); - let result = parse_v3_metadata(&mut data); - assert!(result.is_err()); - match result.unwrap_err() { - GratError::XdrDecodingFailed { type_name, .. } => { - assert_eq!(type_name, "TransactionMeta"); - } - e => panic!("expected XdrDecodingFailed, got {e}"), - } - } - - #[test] - fn test_v3_without_soroban_meta_returns_ok() { - let meta = TransactionMeta::V3(TransactionMetaV3 { - ext: ExtensionPoint::V0, - tx_changes_before: vec![].try_into().unwrap(), - operations: vec![OperationMeta { - changes: vec![].try_into().unwrap(), - }] - .try_into() - .unwrap(), - tx_changes_after: vec![].try_into().unwrap(), - soroban_meta: None, - }); - let b64 = XdrCodec::to_xdr_base64(&meta).unwrap(); - let mut data = serde_json::json!({ - "resultMetaXdr": b64, - }); - let result = parse_v3_metadata(&mut data); - assert!(result.is_ok()); - assert!(data.get("events").is_none()); - assert!(data.get("diagnosticEventsXdr").is_none()); - assert!(data.get("resourceFee").is_none()); - } - - #[test] - fn test_v1_ext_resource_fee_extraction() { - let meta = make_v3_meta_with_v1_ext(500, 300, 100); - let result_b64 = XdrCodec::to_xdr_base64(&make_tx_result(999)).unwrap(); - let meta_b64 = XdrCodec::to_xdr_base64(&meta).unwrap(); - - let mut data = serde_json::json!({ - "resultXdr": result_b64, - "resultMetaXdr": meta_b64, - }); - - let result = parse_v3_metadata(&mut data); - assert!(result.is_ok()); - - assert_eq!(data["inclusionFee"], serde_json::json!(99)); - - let fee = data["resourceFee"].as_object().expect("resourceFee"); - assert_eq!(fee["totalNonRefundableResourceFeeCharged"], 500); - assert_eq!(fee["totalRefundableResourceFeeCharged"], 300); - assert_eq!(fee["rentFeeCharged"], 100); - } - - #[test] - fn test_v0_ext_does_not_inject_resource_fee() { - let soroban = SorobanTransactionMeta { - ext: SorobanTransactionMetaExt::V0, - events: vec![].try_into().unwrap(), - return_value: ScVal::Void, - diagnostic_events: vec![].try_into().unwrap(), - }; - let meta = make_v3_meta_with_soroban(soroban); - let meta_b64 = XdrCodec::to_xdr_base64(&meta).unwrap(); - - let mut data = serde_json::json!({ - "resultMetaXdr": meta_b64, - }); - - let result = parse_v3_metadata(&mut data); - assert!(result.is_ok()); - assert!(data.get("resourceFee").is_none()); - } - - #[test] - fn test_events_extracted() { - let event = ContractEvent { - ext: ExtensionPoint::V0, - contract_id: None, - type_: ContractEventType::Contract, - body: ContractEventBody::V0(ContractEventV0 { - topics: vec![].try_into().unwrap(), - data: ScVal::I32(42), - }), - }; - let diagnostic = DiagnosticEvent { - in_successful_contract_call: true, - event: event.clone(), - }; - let soroban = SorobanTransactionMeta { - ext: SorobanTransactionMetaExt::V0, - events: vec![event.clone()].try_into().unwrap(), - return_value: ScVal::I32(99), - diagnostic_events: vec![diagnostic].try_into().unwrap(), - }; - let meta = make_v3_meta_with_soroban(soroban); - let meta_b64 = XdrCodec::to_xdr_base64(&meta).unwrap(); - - let mut data = serde_json::json!({ - "resultMetaXdr": meta_b64, - }); - - let result = parse_v3_metadata(&mut data); - assert!(result.is_ok()); - - let events = data["events"]["contractEventsXdr"] - .as_array() - .expect("contractEventsXdr"); - assert_eq!(events.len(), 1); - - let diag = data["diagnosticEventsXdr"] - .as_array() - .expect("diagnosticEventsXdr"); - assert_eq!(diag.len(), 1); - - let rv = data["returnValue"].as_str().expect("returnValue"); - assert!(!rv.is_empty()); - } - - #[test] - fn test_void_return_not_injected() { - let soroban = SorobanTransactionMeta { - ext: SorobanTransactionMetaExt::V0, - events: vec![].try_into().unwrap(), - return_value: ScVal::Void, - diagnostic_events: vec![].try_into().unwrap(), - }; - let meta = make_v3_meta_with_soroban(soroban); - let meta_b64 = XdrCodec::to_xdr_base64(&meta).unwrap(); - - let mut data = serde_json::json!({ - "resultMetaXdr": meta_b64, - }); - - let result = parse_v3_metadata(&mut data); - assert!(result.is_ok()); - assert!(data.get("returnValue").is_none()); - } - - #[test] - fn test_invalid_result_xdr_does_not_fail() { - let meta = make_v3_meta_with_v1_ext(100, 50, 25); - let meta_b64 = XdrCodec::to_xdr_base64(&meta).unwrap(); - - let mut data = serde_json::json!({ - "resultXdr": "!!!bad!!!", - "resultMetaXdr": meta_b64, - }); - - let result = parse_v3_metadata(&mut data); - assert!(result.is_ok()); - - assert_eq!(data.get("inclusionFee").unwrap().as_i64(), Some(-175)); - - assert!(data.get("resourceFee").is_some()); - } -} diff --git a/crates/core/src/decode/return_decoder.rs b/crates/core/src/decode/return_decoder.rs new file mode 100644 index 00000000..bf66f5ea --- /dev/null +++ b/crates/core/src/decode/return_decoder.rs @@ -0,0 +1,543 @@ +use crate::spec::decoder::{ContractFunction, ContractSpec, ContractStructDef}; +use serde_json::{json, Value}; +use stellar_xdr::curr::{ScSpecTypeDef, ScVal}; + +/// Decoder for contract return values, converting raw `ScVal` structures +/// into typed JSON representations based on contract specifications. +#[derive(Debug, Clone, Default)] +pub struct ReturnValueDecoder; + +impl ReturnValueDecoder { + pub fn new() -> Self { + Self + } + + /// Decodes a raw return `ScVal` using the provided target function's return specification + /// and optional `ContractSpec` (for UDT struct/enum lookups). + pub fn decode( + &self, + val: &ScVal, + type_def: Option<&ScSpecTypeDef>, + contract_spec: Option<&ContractSpec>, + ) -> Value { + Self::decode_value(val, type_def, contract_spec) + } + + /// Decodes a raw return `ScVal` into a formatted String (e.g. JSON string or pretty representation). + pub fn decode_to_string( + &self, + val: &ScVal, + type_def: Option<&ScSpecTypeDef>, + contract_spec: Option<&ContractSpec>, + ) -> String { + let decoded = self.decode(val, type_def, contract_spec); + match decoded { + Value::String(s) => s, + other => serde_json::to_string(&other).unwrap_or_else(|_| other.to_string()), + } + } + + /// Decodes a function's return value directly given a target `ContractFunction`. + pub fn decode_function_return( + &self, + val: &ScVal, + func: &ContractFunction, + contract_spec: Option<&ContractSpec>, + ) -> Value { + self.decode(val, func.return_type_def.as_ref(), contract_spec) + } + + fn decode_value( + val: &ScVal, + type_def: Option<&ScSpecTypeDef>, + contract_spec: Option<&ContractSpec>, + ) -> Value { + let Some(td) = type_def else { + return Self::decode_dynamic(val); + }; + + match td { + ScSpecTypeDef::Void => Value::Null, + ScSpecTypeDef::Val => Self::decode_dynamic(val), + ScSpecTypeDef::Bool => match val { + ScVal::Bool(b) => json!(*b), + _ => Self::decode_dynamic(val), + }, + ScSpecTypeDef::U32 => match val { + ScVal::U32(u) => json!(*u), + _ => Self::decode_dynamic(val), + }, + ScSpecTypeDef::I32 => match val { + ScVal::I32(i) => json!(*i), + _ => Self::decode_dynamic(val), + }, + ScSpecTypeDef::U64 => match val { + ScVal::U64(u) => json!(*u), + _ => Self::decode_dynamic(val), + }, + ScSpecTypeDef::I64 => match val { + ScVal::I64(i) => json!(*i), + _ => Self::decode_dynamic(val), + }, + ScSpecTypeDef::Timepoint => match val { + ScVal::Timepoint(t) => json!(t.0), + ScVal::U64(u) => json!(*u), + _ => Self::decode_dynamic(val), + }, + ScSpecTypeDef::Duration => match val { + ScVal::Duration(d) => json!(d.0), + ScVal::U64(u) => json!(*u), + _ => Self::decode_dynamic(val), + }, + ScSpecTypeDef::U128 => match val { + ScVal::U128(u) => { + let num = (u128::from(u.hi) << 64) | u128::from(u.lo); + json!(num.to_string()) + } + _ => Self::decode_dynamic(val), + }, + ScSpecTypeDef::I128 => match val { + ScVal::I128(i) => { + #[allow(clippy::cast_possible_wrap)] + let lo = u128::from(i.lo) as i128; + let num = (i128::from(i.hi) << 64) | lo; + json!(num.to_string()) + } + _ => Self::decode_dynamic(val), + }, + ScSpecTypeDef::U256 => match val { + ScVal::U256(u) => { + json!(format!( + "0x{:016x}{:016x}{:016x}{:016x}", + u.hi_hi, u.hi_lo, u.lo_hi, u.lo_lo + )) + } + _ => Self::decode_dynamic(val), + }, + ScSpecTypeDef::I256 => match val { + ScVal::I256(i) => { + json!(format!( + "0x{:016x}{:016x}{:016x}{:016x}", + i.hi_hi, i.hi_lo, i.lo_hi, i.lo_lo + )) + } + _ => Self::decode_dynamic(val), + }, + ScSpecTypeDef::Bytes | ScSpecTypeDef::BytesN(_) => match val { + ScVal::Bytes(b) => { + let hex_str: String = b.iter().map(|byte| format!("{byte:02x}")).collect(); + json!(format!("0x{hex_str}")) + } + _ => Self::decode_dynamic(val), + }, + ScSpecTypeDef::String => match val { + ScVal::String(s) => json!(s.to_string()), + ScVal::Symbol(s) => json!(s.to_string()), + _ => Self::decode_dynamic(val), + }, + ScSpecTypeDef::Symbol => match val { + ScVal::Symbol(s) => json!(s.to_string()), + ScVal::String(s) => json!(s.to_string()), + _ => Self::decode_dynamic(val), + }, + ScSpecTypeDef::Address => match val { + ScVal::Address(addr) => { + json!(crate::types::address::Address::from_sc_address(addr).to_string()) + } + _ => Self::decode_dynamic(val), + }, + ScSpecTypeDef::Error => match val { + ScVal::Error(e) => json!(format!("{e:?}")), + _ => Self::decode_dynamic(val), + }, + ScSpecTypeDef::Option(opt_spec) => match val { + ScVal::Void => Value::Null, + ScVal::Vec(Some(v)) if v.is_empty() => Value::Null, + ScVal::Vec(Some(v)) if v.len() == 1 => { + Self::decode_value(&v[0], Some(&opt_spec.value_type), contract_spec) + } + other => Self::decode_value(other, Some(&opt_spec.value_type), contract_spec), + }, + ScSpecTypeDef::Result(res_spec) => match val { + ScVal::Vec(Some(v)) if v.len() == 2 => match &v[0] { + ScVal::Symbol(sym) if sym.to_string() == "Ok" => { + let ok_val = + Self::decode_value(&v[1], Some(&res_spec.ok_type), contract_spec); + json!({ "Ok": ok_val }) + } + ScVal::Symbol(sym) if sym.to_string() == "Err" => { + let err_val = + Self::decode_value(&v[1], Some(&res_spec.error_type), contract_spec); + json!({ "Err": err_val }) + } + _ => Self::decode_dynamic(val), + }, + ScVal::Error(e) => json!({ "Err": format!("{e:?}") }), + _ => Self::decode_dynamic(val), + }, + ScSpecTypeDef::Vec(vec_spec) => match val { + ScVal::Vec(Some(v)) => { + let items: Vec = v + .iter() + .map(|item| { + Self::decode_value(item, Some(&vec_spec.element_type), contract_spec) + }) + .collect(); + Value::Array(items) + } + _ => Self::decode_dynamic(val), + }, + ScSpecTypeDef::Map(map_spec) => match val { + ScVal::Map(Some(m)) => { + let mut obj = serde_json::Map::new(); + let mut can_be_obj = true; + let mut pairs = Vec::new(); + + for entry in m.iter() { + let key_val = + Self::decode_value(&entry.key, Some(&map_spec.key_type), contract_spec); + let val_val = Self::decode_value( + &entry.val, + Some(&map_spec.value_type), + contract_spec, + ); + + if let Value::String(ref k_str) = key_val { + obj.insert(k_str.clone(), val_val); + } else { + can_be_obj = false; + pairs.push(json!([key_val, val_val])); + } + } + + if can_be_obj { + Value::Object(obj) + } else { + Value::Array(pairs) + } + } + _ => Self::decode_dynamic(val), + }, + ScSpecTypeDef::Tuple(tuple_spec) => match val { + ScVal::Vec(Some(v)) => { + let items: Vec = v + .iter() + .enumerate() + .map(|(i, item)| { + let elem_td = tuple_spec.value_types.get(i); + Self::decode_value(item, elem_td, contract_spec) + }) + .collect(); + Value::Array(items) + } + _ => Self::decode_dynamic(val), + }, + ScSpecTypeDef::Udt(udt_spec) => { + let udt_name = udt_spec.name.to_string(); + if let Some(cs) = contract_spec { + // 1. Check structs + if let Some(struct_def) = cs.structs.iter().find(|s| s.name == udt_name) { + return Self::decode_struct(val, struct_def, cs); + } + // 2. Check enums + if let Some(enum_def) = cs.enums.iter().find(|e| e.name == udt_name) { + return Self::decode_enum(val, enum_def); + } + // 3. Check unions + if let Some(union_def) = cs.unions.iter().find(|u| u.name == udt_name) { + return Self::decode_union(val, union_def, cs); + } + } + // Fallback to dynamic decoding if UDT definition not in spec + Self::decode_dynamic(val) + } + } + } + + fn decode_struct(val: &ScVal, struct_def: &ContractStructDef, cs: &ContractSpec) -> Value { + match val { + ScVal::Map(Some(m)) => { + let mut map_obj = serde_json::Map::new(); + for field in &struct_def.fields { + let matching_entry = m.iter().find(|entry| match &entry.key { + ScVal::Symbol(s) => s.to_string() == field.name, + ScVal::String(s) => s.to_string() == field.name, + _ => false, + }); + + let field_value = match matching_entry { + Some(entry) => Self::decode_value(&entry.val, field.type_def.as_ref(), Some(cs)), + None => Value::Null, + }; + map_obj.insert(field.name.clone(), field_value); + } + Value::Object(map_obj) + } + ScVal::Vec(Some(v)) => { + let mut map_obj = serde_json::Map::new(); + for (i, field) in struct_def.fields.iter().enumerate() { + let field_value = match v.get(i) { + Some(item) => Self::decode_value(item, field.type_def.as_ref(), Some(cs)), + None => Value::Null, + }; + map_obj.insert(field.name.clone(), field_value); + } + Value::Object(map_obj) + } + _ => Self::decode_dynamic(val), + } + } + + fn decode_enum(val: &ScVal, enum_def: &crate::spec::decoder::ContractEnumDef) -> Value { + match val { + ScVal::Symbol(sym) => json!(sym.to_string()), + ScVal::String(s) => json!(s.to_string()), + ScVal::U32(u) => { + if let Some(case) = enum_def.cases.iter().find(|c| c.value == *u) { + json!(case.name.clone()) + } else { + json!(*u) + } + } + ScVal::I32(i) if *i >= 0 => { + #[allow(clippy::cast_sign_loss)] + let u = *i as u32; + if let Some(case) = enum_def.cases.iter().find(|c| c.value == u) { + json!(case.name.clone()) + } else { + json!(*i) + } + } + _ => Self::decode_dynamic(val), + } + } + + fn decode_union(val: &ScVal, union_def: &crate::spec::decoder::ContractUnionDef, cs: &ContractSpec) -> Value { + match val { + ScVal::Symbol(sym) => json!(sym.to_string()), + ScVal::Vec(Some(v)) if !v.is_empty() => { + let variant_name = match &v[0] { + ScVal::Symbol(s) => s.to_string(), + ScVal::String(s) => s.to_string(), + _ => return Self::decode_dynamic(val), + }; + + let matching_case = union_def.cases.iter().find(|c| c.name == variant_name); + match matching_case { + Some(case) => { + if let Some(ref type_defs) = case.value_types { + let payload: Vec = v[1..] + .iter() + .enumerate() + .map(|(i, item)| { + Self::decode_value(item, type_defs.get(i), Some(cs)) + }) + .collect(); + if payload.len() == 1 { + json!({ variant_name: payload[0].clone() }) + } else { + json!({ variant_name: payload }) + } + } else if let Some(ref fields) = case.fields { + let mut map_obj = serde_json::Map::new(); + let items = &v[1..]; + for (i, field) in fields.iter().enumerate() { + let field_val = match items.get(i) { + Some(item) => Self::decode_value(item, field.type_def.as_ref(), Some(cs)), + None => Value::Null, + }; + map_obj.insert(field.name.clone(), field_val); + } + json!({ variant_name: Value::Object(map_obj) }) + } else { + json!(variant_name) + } + } + None => json!({ variant_name: Self::decode_dynamic(&v[1]) }), + } + } + _ => Self::decode_dynamic(val), + } + } + + /// Dynamic fallback to decode any `ScVal` into structured JSON without type specifications. + pub fn decode_dynamic(val: &ScVal) -> Value { + match val { + ScVal::Void => Value::Null, + ScVal::Bool(b) => json!(*b), + ScVal::U32(u) => json!(*u), + ScVal::I32(i) => json!(*i), + ScVal::U64(u) => json!(*u), + ScVal::I64(i) => json!(*i), + ScVal::Timepoint(t) => json!(t.0), + ScVal::Duration(d) => json!(d.0), + ScVal::U128(u) => { + let num = (u128::from(u.hi) << 64) | u128::from(u.lo); + json!(num.to_string()) + } + ScVal::I128(i) => { + #[allow(clippy::cast_possible_wrap)] + let lo = u128::from(i.lo) as i128; + let num = (i128::from(i.hi) << 64) | lo; + json!(num.to_string()) + } + ScVal::U256(u) => { + json!(format!( + "0x{:016x}{:016x}{:016x}{:016x}", + u.hi_hi, u.hi_lo, u.lo_hi, u.lo_lo + )) + } + ScVal::I256(i) => { + json!(format!( + "0x{:016x}{:016x}{:016x}{:016x}", + i.hi_hi, i.hi_lo, i.lo_hi, i.lo_lo + )) + } + ScVal::Bytes(b) => { + let hex_str: String = b.iter().map(|byte| format!("{byte:02x}")).collect(); + json!(format!("0x{hex_str}")) + } + ScVal::String(s) => json!(s.to_string()), + ScVal::Symbol(s) => json!(s.to_string()), + ScVal::Address(addr) => { + json!(crate::types::address::Address::from_sc_address(addr).to_string()) + } + ScVal::Error(e) => json!(format!("{e:?}")), + ScVal::Vec(Some(v)) => { + let items: Vec = v.iter().map(Self::decode_dynamic).collect(); + Value::Array(items) + } + ScVal::Map(Some(m)) => { + let mut obj = serde_json::Map::new(); + let mut all_string_keys = true; + let mut pairs = Vec::new(); + + for entry in m.iter() { + let k = Self::decode_dynamic(&entry.key); + let v = Self::decode_dynamic(&entry.val); + if let Value::String(ref k_str) = k { + obj.insert(k_str.clone(), v); + } else { + all_string_keys = false; + pairs.push(json!([k, v])); + } + } + + if all_string_keys { + Value::Object(obj) + } else { + Value::Array(pairs) + } + } + _ => json!(format!("{val:?}")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::spec::decoder::{ContractStructField, ContractStructDef}; + use stellar_xdr::curr::{ScSymbol, ScVec, VecM}; + + #[test] + fn test_primitive_return_decoding() { + let decoder = ReturnValueDecoder::new(); + assert_eq!( + decoder.decode(&ScVal::U32(42), Some(&ScSpecTypeDef::U32), None), + json!(42) + ); + assert_eq!( + decoder.decode(&ScVal::Bool(true), Some(&ScSpecTypeDef::Bool), None), + json!(true) + ); + assert_eq!( + decoder.decode(&ScVal::Void, Some(&ScSpecTypeDef::Void), None), + Value::Null + ); + } + + #[test] + fn test_struct_return_decoding_map_encoded() { + let decoder = ReturnValueDecoder::new(); + + let struct_def = ContractStructDef { + name: "User".to_string(), + fields: vec![ + ContractStructField { + name: "id".to_string(), + type_name: "U64".to_string(), + doc: None, + type_def: Some(ScSpecTypeDef::U64), + }, + ContractStructField { + name: "name".to_string(), + type_name: "String".to_string(), + doc: None, + type_def: Some(ScSpecTypeDef::String), + }, + ], + doc: None, + }; + + let contract_spec = ContractSpec { + errors: vec![], + functions: vec![], + structs: vec![struct_def], + name: None, + version: None, + enums: vec![], + unions: vec![], + }; + + let map_val = ScVal::Map(Some( + vec![ + stellar_xdr::curr::ScMapEntry { + key: ScVal::Symbol(ScSymbol("id".try_into().unwrap())), + val: ScVal::U64(100), + }, + stellar_xdr::curr::ScMapEntry { + key: ScVal::Symbol(ScSymbol("name".try_into().unwrap())), + val: ScVal::String("Alice".try_into().unwrap()), + }, + ] + .try_into() + .unwrap(), + )); + + let decoded = decoder.decode( + &map_val, + Some(&ScSpecTypeDef::Udt(stellar_xdr::curr::ScSpecTypeUdt { + name: ScSymbol("User".try_into().unwrap()), + })), + Some(&contract_spec), + ); + + assert_eq!(decoded, json!({ "id": 100, "name": "Alice" })); + } + + #[test] + fn test_result_return_decoding() { + let decoder = ReturnValueDecoder::new(); + + let ok_val = ScVal::Vec(Some( + vec![ScVal::Symbol(ScSymbol("Ok".try_into().unwrap())), ScVal::U32(200)] + .try_into() + .unwrap(), + )); + + let res_spec = ScSpecTypeDef::Result(Box::new(stellar_xdr::curr::ScSpecTypeResult { + ok_type: Box::new(ScSpecTypeDef::U32), + error_type: Box::new(ScSpecTypeDef::U32), + })); + + let decoded = decoder.decode(&ok_val, Some(&res_spec), None); + assert_eq!(decoded, json!({ "Ok": 200 })); + } + + #[test] + fn test_dynamic_fallback() { + let val = ScVal::Symbol(ScSymbol("hello".try_into().unwrap())); + assert_eq!(ReturnValueDecoder::decode_dynamic(&val), json!("hello")); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index fc097961..3e24eec4 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -13,8 +13,8 @@ pub mod xdr; pub use decode::{ walk_diagnostic_events, AddressCredential, AddressWithNonce, AuthChain, AuthCredential, - AuthFunctionKind, AuthInvocation, DiagnosticEventKind, DiagnosticEventWalker, - StructuredDiagnosticEvent, + AuthFunctionKind, AuthInvocation, DecodedFunctionCall, DiagnosticEventKind, + DiagnosticEventWalker, FunctionCallDecoder, ReturnValueDecoder, StructuredDiagnosticEvent, }; pub use error::{GratError, GratResult}; pub use network::config::Network; diff --git a/crates/core/src/spec/decoder.rs b/crates/core/src/spec/decoder.rs index 88744830..dff2ab17 100644 --- a/crates/core/src/spec/decoder.rs +++ b/crates/core/src/spec/decoder.rs @@ -20,6 +20,12 @@ pub struct ContractFunction { pub return_type: String, pub doc: Option, + + #[serde(skip_serializing_if = "Option::is_none", default)] + pub return_type_def: Option, + + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub param_defs: Vec<(String, ScSpecTypeDef)>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -29,6 +35,9 @@ pub struct ContractStructField { pub type_name: String, pub doc: Option, + + #[serde(skip_serializing_if = "Option::is_none", default)] + pub type_def: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -40,6 +49,46 @@ pub struct ContractStructDef { pub doc: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContractEnumCase { + pub name: String, + + pub value: u32, + + pub doc: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContractEnumDef { + pub name: String, + + pub cases: Vec, + + pub doc: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContractUnionCase { + pub name: String, + + pub doc: Option, + + #[serde(skip_serializing_if = "Option::is_none", default)] + pub value_types: Option>, + + #[serde(skip_serializing_if = "Option::is_none", default)] + pub fields: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContractUnionDef { + pub name: String, + + pub cases: Vec, + + pub doc: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContractSpec { pub errors: Vec, @@ -51,6 +100,12 @@ pub struct ContractSpec { pub name: Option, pub version: Option, + + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub enums: Vec, + + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub unions: Vec, } pub fn decode_contract_spec(wasm_bytes: &[u8]) -> GratResult { @@ -59,6 +114,8 @@ pub fn decode_contract_spec(wasm_bytes: &[u8]) -> GratResult { let mut errors = Vec::new(); let mut functions = Vec::new(); let mut structs = Vec::new(); + let mut enums = Vec::new(); + let mut unions = Vec::new(); let name = None; let version = None; @@ -75,12 +132,20 @@ pub fn decode_contract_spec(wasm_bytes: &[u8]) -> GratResult { }; let mut params = Vec::new(); + let mut param_defs = Vec::new(); for input in func.inputs.iter() { let param_name = input.name.to_string(); let param_type = format_type_def(&input.type_); - params.push((param_name, param_type)); + params.push((param_name.clone(), param_type)); + param_defs.push((param_name, input.type_.clone())); } + let return_type_def = if func.outputs.is_empty() { + Some(ScSpecTypeDef::Void) + } else { + Some(func.outputs[0].clone()) + }; + let return_type = if func.outputs.is_empty() { "Void".to_string() } else { @@ -92,6 +157,8 @@ pub fn decode_contract_spec(wasm_bytes: &[u8]) -> GratResult { params, return_type, doc, + return_type_def, + param_defs, }); } ScSpecEntry::UdtErrorEnumV0(err_enum) => { @@ -111,6 +178,105 @@ pub fn decode_contract_spec(wasm_bytes: &[u8]) -> GratResult { }); } } + ScSpecEntry::UdtEnumV0(enum_spec) => { + let enum_name = enum_spec.name.to_string(); + let doc = if enum_spec.doc.is_empty() { + None + } else { + Some(enum_spec.doc.to_string()) + }; + let mut cases = Vec::new(); + for case in enum_spec.cases.iter() { + let case_doc = if case.doc.is_empty() { + None + } else { + Some(case.doc.to_string()) + }; + cases.push(ContractEnumCase { + name: case.name.to_string(), + value: case.value, + doc: case_doc, + }); + } + enums.push(ContractEnumDef { + name: enum_name, + cases, + doc, + }); + } + ScSpecEntry::UdtUnionV0(union_spec) => { + let union_name = union_spec.name.to_string(); + let doc = if union_spec.doc.is_empty() { + None + } else { + Some(union_spec.doc.to_string()) + }; + let mut cases = Vec::new(); + for case in union_spec.cases.iter() { + match case { + stellar_xdr::curr::ScSpecUdtUnionCaseV0::VoidV0(c) => { + let case_doc = if c.doc.is_empty() { + None + } else { + Some(c.doc.to_string()) + }; + cases.push(ContractUnionCase { + name: c.name.to_string(), + doc: case_doc, + value_types: None, + fields: None, + }); + } + stellar_xdr::curr::ScSpecUdtUnionCaseV0::TupleV0(c) => { + let case_doc = if c.doc.is_empty() { + None + } else { + Some(c.doc.to_string()) + }; + let value_types: Vec = + c.type_.iter().cloned().collect(); + cases.push(ContractUnionCase { + name: c.name.to_string(), + doc: case_doc, + value_types: Some(value_types), + fields: None, + }); + } + stellar_xdr::curr::ScSpecUdtUnionCaseV0::StructV0(c) => { + let case_doc = if c.doc.is_empty() { + None + } else { + Some(c.doc.to_string()) + }; + let mut fields = Vec::new(); + for field in c.fields.iter() { + let field_doc = if field.doc.is_empty() { + None + } else { + Some(field.doc.to_string()) + }; + fields.push(ContractStructField { + name: field.name.to_string(), + type_name: format_type_def(&field.type_), + doc: field_doc, + type_def: Some(field.type_.clone()), + }); + } + cases.push(ContractUnionCase { + name: c.name.to_string(), + doc: case_doc, + value_types: None, + fields: Some(fields), + }); + } + } + } + unions.push(ContractUnionDef { + name: union_name, + cases, + doc, + }); + } ScSpecEntry::UdtStructV0(struct_spec) => { let struct_name = struct_spec.name.to_string(); let doc = if struct_spec.doc.is_empty() { @@ -132,6 +298,7 @@ pub fn decode_contract_spec(wasm_bytes: &[u8]) -> GratResult { name: field_name, type_name: field_type, doc: field_doc, + type_def: Some(field.type_.clone()), }); } @@ -151,6 +318,8 @@ pub fn decode_contract_spec(wasm_bytes: &[u8]) -> GratResult { structs, name, version, + enums, + unions, }) } diff --git a/crates/core/src/spec/mod.rs b/crates/core/src/spec/mod.rs index fdc6c1ab..c271b767 100644 --- a/crates/core/src/spec/mod.rs +++ b/crates/core/src/spec/mod.rs @@ -2,7 +2,7 @@ pub mod decoder; pub mod resolver; pub use decoder::{ - ContractErrorEntry, ContractFunction, ContractSpec, ContractStructDef, ContractStructField, - SpecParser, + ContractEnumCase, ContractEnumDef, ContractErrorEntry, ContractFunction, ContractSpec, + ContractStructDef, ContractStructField, ContractUnionCase, ContractUnionDef, SpecParser, }; pub use resolver::{ContractId, ResolverStats, SCSpecResolver};