diff --git a/crates/core/src/decode/json_to_scval.rs b/crates/core/src/decode/json_to_scval.rs index 656455ed..977b7d8b 100644 --- a/crates/core/src/decode/json_to_scval.rs +++ b/crates/core/src/decode/json_to_scval.rs @@ -270,7 +270,7 @@ mod tests { ]; for case in cases { - let json = scval_to_json(&case); + let json = scval_to_json(&case, None); let parsed = json_to_scval(&json).expect("should parse"); // For strings, scval_to_json might have been passed a Symbol, but json_to_scval will parse it back as String. @@ -301,7 +301,7 @@ mod tests { .unwrap(), ))); - let json = scval_to_json(&map); + let json = scval_to_json(&map, None); // This should be the fallback array mode assert!(json.is_array()); diff --git a/crates/core/src/decode/mod.rs b/crates/core/src/decode/mod.rs index 79399165..db095c21 100644 --- a/crates/core/src/decode/mod.rs +++ b/crates/core/src/decode/mod.rs @@ -18,6 +18,7 @@ pub mod json_to_scval; pub mod mappings; /// Envelope-level decoding that emits one diagnostic report per operation. pub mod multi_op_decoder; +pub mod recursive_decoder; pub mod report; pub mod resource_analyzer; pub mod return_decoder; @@ -37,6 +38,7 @@ pub use enum_decoder::EnumDecoder; pub use function_call_decoder::{DecodedArgument, DecodedFunctionCall, FunctionCallDecoder}; pub use json_to_scval::json_to_scval; pub use multi_op_decoder::{decode_transaction_with_op_filter, MultiOpDecoder}; +pub use recursive_decoder::{RecursiveTypeDecoder, TypeRef}; pub use resource_analyzer::{ MetricDiagnostic, MetricKind, ResourceDiagnostics, ResourceUsageAnalyzer, TransactionResultMeta, }; diff --git a/crates/core/src/decode/multi_op_decoder.rs b/crates/core/src/decode/multi_op_decoder.rs index ec8103d4..1cf87c5e 100644 --- a/crates/core/src/decode/multi_op_decoder.rs +++ b/crates/core/src/decode/multi_op_decoder.rs @@ -401,7 +401,7 @@ fn compact_json(value: &Value) -> String { } fn format_scval(value: &ScVal) -> String { - compact_json(&crate::decode::scval_to_json(value)) + compact_json(&crate::decode::scval_to_json(value, None)) } fn is_soroban_operation(operation: &Operation) -> bool { diff --git a/crates/core/src/decode/recursive_decoder.rs b/crates/core/src/decode/recursive_decoder.rs new file mode 100644 index 00000000..2baeed81 --- /dev/null +++ b/crates/core/src/decode/recursive_decoder.rs @@ -0,0 +1,400 @@ +//! Type-aware recursive decoding of Soroban values. + +use crate::decode::scval_to_json; +use crate::spec::decoder::{ContractSpec, ContractStructDef}; +use serde_json::{json, Map, Value}; +use stellar_xdr::curr::{ScSpecTypeDef, ScVal}; + +/// The type information available while decoding one value. +#[derive(Clone, Copy)] +pub struct TypeRef<'a> { + pub type_def: &'a ScSpecTypeDef, + pub contract_spec: Option<&'a ContractSpec>, +} + +impl<'a> TypeRef<'a> { + pub fn new(type_def: &'a ScSpecTypeDef, contract_spec: Option<&'a ContractSpec>) -> Self { + Self { + type_def, + contract_spec, + } + } +} + +/// Dispatches a value to the decoder for its current type and carries the +/// contract specification into every nested value. +#[derive(Debug, Clone, Copy, Default)] +pub struct RecursiveTypeDecoder; + +impl RecursiveTypeDecoder { + pub fn new() -> Self { + Self + } + + pub fn decode<'a>(&self, val: &ScVal, type_ref: Option>) -> Value { + let Some(type_ref) = type_ref else { + return scval_to_json(val, None); + }; + self.decode_type(val, type_ref.type_def, type_ref.contract_spec, 0) + } + + fn decode_type( + &self, + val: &ScVal, + type_def: &ScSpecTypeDef, + contract_spec: Option<&ContractSpec>, + depth: usize, + ) -> Value { + if depth > 100 { + return scval_to_json(val, None); + } + let child = |value: &ScVal, td: &ScSpecTypeDef| { + self.decode_type(value, td, contract_spec, depth + 1) + }; + + match type_def { + ScSpecTypeDef::Void => Value::Null, + ScSpecTypeDef::Val => scval_to_json(val, None), + ScSpecTypeDef::Bool => match val { + ScVal::Bool(v) => json!(v), + _ => scval_to_json(val, None), + }, + ScSpecTypeDef::U32 => match val { + ScVal::U32(v) => json!(v), + _ => scval_to_json(val, None), + }, + ScSpecTypeDef::I32 => match val { + ScVal::I32(v) => json!(v), + _ => scval_to_json(val, None), + }, + ScSpecTypeDef::U64 => match val { + ScVal::U64(v) => json!(v), + _ => scval_to_json(val, None), + }, + ScSpecTypeDef::I64 => match val { + ScVal::I64(v) => json!(v), + _ => scval_to_json(val, None), + }, + ScSpecTypeDef::Timepoint => match val { + ScVal::Timepoint(v) => json!(v.0), + ScVal::U64(v) => json!(v), + _ => scval_to_json(val, None), + }, + ScSpecTypeDef::Duration => match val { + ScVal::Duration(v) => json!(v.0), + ScVal::U64(v) => json!(v), + _ => scval_to_json(val, None), + }, + ScSpecTypeDef::U128 + | ScSpecTypeDef::I128 + | ScSpecTypeDef::U256 + | ScSpecTypeDef::I256 => scval_to_json(val, None), + ScSpecTypeDef::Bytes + | ScSpecTypeDef::BytesN(_) + | ScSpecTypeDef::String + | ScSpecTypeDef::Symbol + | ScSpecTypeDef::Address + | ScSpecTypeDef::Error => scval_to_json(val, None), + ScSpecTypeDef::Option(spec) => match val { + ScVal::Void => Value::Null, + ScVal::Vec(Some(values)) if values.is_empty() => Value::Null, + ScVal::Vec(Some(values)) if values.len() == 1 => { + child(&values[0], &spec.value_type) + } + _ => child(val, &spec.value_type), + }, + ScSpecTypeDef::Result(spec) => match val { + ScVal::Vec(Some(values)) if values.len() == 2 => { + let name = match &values[0] { + ScVal::Symbol(v) => v.to_string(), + ScVal::String(v) => v.to_string(), + _ => return scval_to_json(val, None), + }; + let payload = if name == "Ok" { + child(&values[1], &spec.ok_type) + } else if name == "Err" { + child(&values[1], &spec.error_type) + } else { + return scval_to_json(val, None); + }; + json!({ (name): payload }) + } + _ => scval_to_json(val, None), + }, + ScSpecTypeDef::Vec(spec) => match val { + ScVal::Vec(Some(values)) => Value::Array( + values + .iter() + .map(|v| child(v, &spec.element_type)) + .collect(), + ), + _ => scval_to_json(val, None), + }, + ScSpecTypeDef::Map(spec) => match val { + ScVal::Map(Some(entries)) => { + let mut object = Map::new(); + let mut pairs = Vec::new(); + for entry in entries.iter() { + let key = child(&entry.key, &spec.key_type); + let value = child(&entry.val, &spec.value_type); + if let Value::String(key) = key { + object.insert(key, value); + } else { + pairs.push(json!([key, value])); + } + } + if pairs.is_empty() { + Value::Object(object) + } else { + Value::Array(pairs) + } + } + _ => scval_to_json(val, None), + }, + ScSpecTypeDef::Tuple(spec) => match val { + ScVal::Vec(Some(values)) => Value::Array( + values + .iter() + .enumerate() + .map(|(i, value)| { + spec.value_types + .get(i) + .map_or_else(|| scval_to_json(value, None), |td| child(value, td)) + }) + .collect(), + ), + _ => scval_to_json(val, None), + }, + ScSpecTypeDef::Udt(spec) => { + let Some(contract_spec) = contract_spec else { + return scval_to_json(val, None); + }; + let name = spec.name.to_string(); + if let Some(def) = contract_spec.structs.iter().find(|def| def.name == name) { + self.decode_struct(val, def, contract_spec, depth) + } else if let Some(def) = contract_spec.enums.iter().find(|def| def.name == name) { + self.decode_enum(val, def) + } else if let Some(def) = contract_spec.unions.iter().find(|def| def.name == name) { + self.decode_union(val, def, contract_spec, depth) + } else { + scval_to_json(val, None) + } + } + } + } + + fn decode_struct( + &self, + val: &ScVal, + def: &ContractStructDef, + spec: &ContractSpec, + depth: usize, + ) -> Value { + let mut object = Map::new(); + match val { + ScVal::Map(Some(entries)) => { + for field in &def.fields { + let entry = entries.iter().find(|entry| match &entry.key { + ScVal::Symbol(k) => k.to_string() == field.name, + ScVal::String(k) => k.to_string() == field.name, + _ => false, + }); + object.insert( + field.name.clone(), + entry + .and_then(|entry| { + field.type_def.as_ref().map(|td| { + self.decode_type(&entry.val, td, Some(spec), depth + 1) + }) + }) + .unwrap_or(Value::Null), + ); + } + } + ScVal::Vec(Some(values)) => { + for (i, field) in def.fields.iter().enumerate() { + object.insert( + field.name.clone(), + values + .get(i) + .and_then(|value| { + field + .type_def + .as_ref() + .map(|td| self.decode_type(value, td, Some(spec), depth + 1)) + }) + .unwrap_or(Value::Null), + ); + } + } + _ => return scval_to_json(val, None), + } + Value::Object(object) + } + + fn decode_enum(&self, val: &ScVal, def: &crate::spec::decoder::ContractEnumDef) -> Value { + match val { + ScVal::Symbol(v) => json!(v.to_string()), + ScVal::String(v) => json!(v.to_string()), + ScVal::U32(v) => def + .cases + .iter() + .find(|case| case.value == *v) + .map_or_else(|| json!(v), |case| json!(case.name)), + _ => scval_to_json(val, None), + } + } + + fn decode_union( + &self, + val: &ScVal, + def: &crate::spec::decoder::ContractUnionDef, + spec: &ContractSpec, + depth: usize, + ) -> Value { + let ScVal::Vec(Some(values)) = val else { + return scval_to_json(val, None); + }; + let Some(first) = values.first() else { + return scval_to_json(val, None); + }; + let name = match first { + ScVal::Symbol(v) => v.to_string(), + ScVal::String(v) => v.to_string(), + _ => return scval_to_json(val, None), + }; + let Some(case) = def.cases.iter().find(|case| case.name == name) else { + return scval_to_json(val, None); + }; + if let Some(types) = &case.value_types { + let payload: Vec = values[1..] + .iter() + .enumerate() + .map(|(i, value)| { + types.get(i).map_or_else( + || scval_to_json(value, None), + |td| self.decode_type(value, td, Some(spec), depth + 1), + ) + }) + .collect(); + if payload.len() == 1 { + json!({ (name): payload[0] }) + } else { + json!({ (name): payload }) + } + } else { + json!(name) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::spec::decoder::{ + ContractEnumCase, ContractEnumDef, ContractSpec, ContractStructDef, ContractStructField, + }; + use serde_json::json; + use stellar_xdr::curr::{ScMapEntry, ScSpecTypeTuple, ScSpecTypeUdt, ScSymbol, StringM}; + + fn symbol(value: &str) -> ScVal { + ScVal::Symbol(ScSymbol( + StringM::try_from(value.as_bytes().to_vec()).unwrap(), + )) + } + + fn map(entries: Vec<(&str, ScVal)>) -> ScVal { + ScVal::Map(Some( + entries + .into_iter() + .map(|(key, val)| ScMapEntry { + key: symbol(key), + val, + }) + .collect::>() + .try_into() + .unwrap(), + )) + } + + #[test] + fn nested_udts_keep_type_context_for_struct_enum_and_tuple() { + let status = ScSpecTypeDef::Udt(ScSpecTypeUdt { + name: "Status".try_into().unwrap(), + }); + let inner = ScSpecTypeDef::Udt(ScSpecTypeUdt { + name: "Inner".try_into().unwrap(), + }); + let tuple = ScSpecTypeDef::Tuple(Box::new(ScSpecTypeTuple { + value_types: vec![ScSpecTypeDef::U32, ScSpecTypeDef::U32] + .try_into() + .unwrap(), + })); + let spec = ContractSpec { + errors: vec![], + functions: vec![], + structs: vec![ + ContractStructDef { + name: "Outer".to_string(), + fields: vec![ContractStructField { + name: "inner".to_string(), + type_name: "Inner".to_string(), + doc: None, + type_def: Some(inner.clone()), + }], + doc: None, + }, + ContractStructDef { + name: "Inner".to_string(), + fields: vec![ + ContractStructField { + name: "status".to_string(), + type_name: "Status".to_string(), + doc: None, + type_def: Some(status.clone()), + }, + ContractStructField { + name: "pair".to_string(), + type_name: "(U32,U32)".to_string(), + doc: None, + type_def: Some(tuple), + }, + ], + doc: None, + }, + ], + enums: vec![ContractEnumDef { + name: "Status".to_string(), + cases: vec![ContractEnumCase { + name: "Active".to_string(), + value: 1, + doc: None, + }], + doc: None, + }], + unions: vec![], + name: None, + version: None, + }; + let value = map(vec![( + "inner", + map(vec![ + ("status", ScVal::U32(1)), + ( + "pair", + ScVal::Vec(Some(vec![ScVal::U32(2), ScVal::U32(3)].try_into().unwrap())), + ), + ]), + )]); + let outer = ScSpecTypeDef::Udt(ScSpecTypeUdt { + name: "Outer".try_into().unwrap(), + }); + + let decoded = + RecursiveTypeDecoder::new().decode(&value, Some(TypeRef::new(&outer, Some(&spec)))); + assert_eq!( + decoded, + json!({"inner": {"status": "Active", "pair": [2, 3]}}) + ); + } +} diff --git a/crates/core/src/decode/return_decoder.rs b/crates/core/src/decode/return_decoder.rs index 98b29b64..71011a9b 100644 --- a/crates/core/src/decode/return_decoder.rs +++ b/crates/core/src/decode/return_decoder.rs @@ -22,7 +22,10 @@ impl ReturnValueDecoder { type_def: Option<&ScSpecTypeDef>, contract_spec: Option<&ContractSpec>, ) -> Value { - Self::decode_value(val, type_def, contract_spec) + RecursiveTypeDecoder::new().decode( + val, + type_def.map(|type_def| TypeRef::new(type_def, contract_spec)), + ) } /// Decodes a raw return `ScVal` into a formatted String (e.g. JSON string or pretty representation). diff --git a/crates/core/src/decode/scval_to_json.rs b/crates/core/src/decode/scval_to_json.rs index b42e97bf..a2a410a8 100644 --- a/crates/core/src/decode/scval_to_json.rs +++ b/crates/core/src/decode/scval_to_json.rs @@ -32,7 +32,11 @@ const MAX_SCVAL_DEPTH: usize = 100; /// The conversion never fails and never panics: unsupported combinations /// simply degrade to a best-effort JSON representation, and nesting beyond /// [`MAX_SCVAL_DEPTH`] is truncated in place rather than recursed into. -pub fn scval_to_json(val: &ScVal) -> Value { +pub fn scval_to_json(val: &ScVal, type_ref: Option>) -> Value { + if let Some(type_ref) = type_ref { + return crate::decode::recursive_decoder::RecursiveTypeDecoder::new() + .decode(val, Some(type_ref)); + } convert(val, 0) }