From 6f52ff30e4d083a5e2e5a3fc3b4877aaf5c80a93 Mon Sep 17 00:00:00 2001 From: Lewechi Date: Sun, 30 Aug 2026 14:00:43 +0100 Subject: [PATCH 1/4] feat: implement json_to_scval converter for reverse SCVal parsing --- crates/core/src/decode/json_to_scval.rs | 287 ++++++++++++++++++++++++ crates/core/src/decode/mod.rs | 2 + 2 files changed, 289 insertions(+) create mode 100644 crates/core/src/decode/json_to_scval.rs diff --git a/crates/core/src/decode/json_to_scval.rs b/crates/core/src/decode/json_to_scval.rs new file mode 100644 index 00000000..2522b2d5 --- /dev/null +++ b/crates/core/src/decode/json_to_scval.rs @@ -0,0 +1,287 @@ +use anyhow::{anyhow, Result}; +use serde_json::Value; +use std::str::FromStr; +use stellar_xdr::curr::{ + ContractExecutable, Hash, Int128Parts, Int256Parts, ScAddress, ScBytes, ScContractInstance, + ScError, ScErrorCode, ScMap, ScMapEntry, ScNonceKey, ScString, ScSymbol, ScVal, ScVec, StringM, + UInt128Parts, UInt256Parts, +}; + +const MAX_SCVAL_DEPTH: usize = 100; + +/// Parses a `serde_json::Value` back into an `ScVal`. +pub fn json_to_scval(value: &Value) -> Result { + convert(value, 0) +} + +fn convert(value: &Value, depth: usize) -> Result { + if depth > MAX_SCVAL_DEPTH { + return Err(anyhow!("max recursion depth ({}) exceeded", MAX_SCVAL_DEPTH)); + } + + match value { + Value::Null => Ok(ScVal::Void), + Value::Bool(b) => Ok(ScVal::Bool(*b)), + Value::Number(num) => { + if let Some(i) = num.as_i64() { + if i >= 0 && i <= u32::MAX as i64 { + Ok(ScVal::U32(i as u32)) + } else if i >= i32::MIN as i64 && i <= i32::MAX as i64 { + Ok(ScVal::I32(i as i32)) + } else if i < 0 { + Ok(ScVal::I64(i)) + } else { + Ok(ScVal::U64(i as u64)) + } + } else if let Some(u) = num.as_u64() { + if u <= u32::MAX as u64 { + Ok(ScVal::U32(u as u32)) + } else { + Ok(ScVal::U64(u)) + } + } else { + Err(anyhow!("unsupported float or out of bounds number: {}", num)) + } + } + Value::String(s) => convert_string(s), + Value::Array(arr) => { + // Check if this is the lossless map fallback: [{"key": ..., "value": ...}, ...] + let is_lossless_map = !arr.is_empty() && arr.iter().all(|v| { + v.as_object().map_or(false, |obj| { + obj.len() == 2 && obj.contains_key("key") && obj.contains_key("value") + }) + }); + + if is_lossless_map { + let mut entries = Vec::with_capacity(arr.len()); + for item in arr { + let obj = item.as_object().unwrap(); + let key = convert(obj.get("key").unwrap(), depth + 1)?; + let val = convert(obj.get("value").unwrap(), depth + 1)?; + entries.push(ScMapEntry { key, val }); + } + return Ok(ScVal::Map(Some(ScMap( + entries.try_into().map_err(|_| anyhow!("map too large"))?, + )))); + } + + // Normal Array + let mut items = Vec::with_capacity(arr.len()); + for item in arr { + items.push(convert(item, depth + 1)?); + } + Ok(ScVal::Vec(Some(ScVec( + items.try_into().map_err(|_| anyhow!("array too large"))?, + )))) + } + Value::Object(obj) => { + if obj.contains_key("__truncated__") { + return Err(anyhow!("cannot convert truncated JSON back to ScVal")); + } + + // Check for explicit markers + if obj.contains_key("type") && obj.contains_key("code") && obj.len() == 2 { + if let Ok(err) = parse_error(obj) { + return Ok(ScVal::Error(err)); + } + } + + if obj.contains_key("nonce") && obj.len() == 1 { + if let Some(nonce_val) = obj.get("nonce").and_then(|v| v.as_i64()) { + return Ok(ScVal::LedgerKeyNonce(ScNonceKey { nonce: nonce_val })); + } + } + + if obj.contains_key("executable") && obj.contains_key("storage") { + if let Ok(instance) = parse_contract_instance(obj, depth) { + return Ok(ScVal::ContractInstance(instance)); + } + } + + // Normal Map with stringified keys + let mut entries = Vec::with_capacity(obj.len()); + for (k, v) in obj { + let key_val = convert_string(k)?; + let val_val = convert(v, depth + 1)?; + entries.push(ScMapEntry { + key: key_val, + val: val_val, + }); + } + Ok(ScVal::Map(Some(ScMap( + entries.try_into().map_err(|_| anyhow!("map too large"))?, + )))) + } + } +} + +fn convert_string(s: &str) -> Result { + if s == "LedgerKeyContractInstance" { + return Ok(ScVal::LedgerKeyContractInstance); + } + if let Some(hex) = s.strip_prefix("0x") { + if let Ok(bytes) = hex::decode(hex) { + return Ok(ScVal::Bytes(ScBytes( + bytes.try_into().map_err(|_| anyhow!("bytes too large"))?, + ))); + } + } + if s.starts_with('G') && s.len() == 56 { + if let Ok(pubkey) = stellar_strkey::ed25519::PublicKey::from_string(s) { + return Ok(ScVal::Address(ScAddress::Account( + stellar_xdr::curr::AccountId(stellar_xdr::curr::PublicKey::PublicKeyTypeEd25519( + stellar_xdr::curr::Uint256(pubkey.0), + )), + ))); + } + } + if s.starts_with('C') && s.len() == 56 { + if let Ok(contract) = stellar_strkey::Contract::from_string(s) { + return Ok(ScVal::Address(ScAddress::Contract(Hash(contract.0)))); + } + } + + // Try parsing large numbers (u128, i128). We skip 256-bit parsing here as a simplification, + // falling back to String if they exceed u128. + if let Ok(u) = u128::from_str(s) { + if u > u64::MAX as u128 { + let hi = (u >> 64) as u64; + let lo = u as u64; + return Ok(ScVal::U128(UInt128Parts { hi, lo })); + } + } + if let Ok(i) = i128::from_str(s) { + if i < i64::MIN as i128 || i > i64::MAX as i128 { + let hi = (i >> 64) as i64; + let lo = (i & 0xFFFFFFFFFFFFFFFF) as u64; + return Ok(ScVal::I128(Int128Parts { hi, lo })); + } + } + + // Default to String. (Could be Symbol, but we can't infer that purely from a JSON string). + Ok(ScVal::String(ScString( + StringM::try_from(s.as_bytes().to_vec()).map_err(|_| anyhow!("string too long"))?, + ))) +} + +fn parse_error(obj: &serde_json::Map) -> Result { + let err_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let code_val = obj.get("code").unwrap(); + + let parse_code = |c: &Value| -> Result { + let code_str = c.as_str().ok_or_else(|| anyhow!("code must be string"))?; + match code_str { + "UnknownError" => Ok(ScErrorCode::UnknownError), + "UnexpectedType" => Ok(ScErrorCode::UnexpectedType), + "UnexpectedSize" => Ok(ScErrorCode::UnexpectedSize), + "MissingValue" => Ok(ScErrorCode::MissingValue), + "InternalError" => Ok(ScErrorCode::InternalError), + "ExceededLimit" => Ok(ScErrorCode::ExceededLimit), + "InvalidAction" => Ok(ScErrorCode::InvalidAction), + "InvalidInput" => Ok(ScErrorCode::InvalidInput), + _ => Err(anyhow!("unknown error code variant {}", code_str)), + } + }; + + match err_type { + "Contract" => { + let code = code_val.as_u64().ok_or_else(|| anyhow!("Contract code must be u32"))? as u32; + Ok(ScError::Contract(code)) + } + "WasmVm" => Ok(ScError::WasmVm(parse_code(code_val)?)), + "Context" => Ok(ScError::Context(parse_code(code_val)?)), + "Storage" => Ok(ScError::Storage(parse_code(code_val)?)), + "Object" => Ok(ScError::Object(parse_code(code_val)?)), + "Crypto" => Ok(ScError::Crypto(parse_code(code_val)?)), + "Events" => Ok(ScError::Events(parse_code(code_val)?)), + "Budget" => Ok(ScError::Budget(parse_code(code_val)?)), + "Value" => Ok(ScError::Value(parse_code(code_val)?)), + "Auth" => Ok(ScError::Auth(parse_code(code_val)?)), + _ => Err(anyhow!("Unknown ScError type: {}", err_type)), + } +} + +fn parse_contract_instance(obj: &serde_json::Map, depth: usize) -> Result { + let exec_obj = obj.get("executable").and_then(|v| v.as_object()).ok_or_else(|| anyhow!("executable must be object"))?; + + let exec_type = exec_obj.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let executable = if exec_type == "Wasm" { + let hash_str = exec_obj.get("wasmHash").and_then(|v| v.as_str()).unwrap_or(""); + let hex = hash_str.strip_prefix("0x").unwrap_or(hash_str); + let bytes = hex::decode(hex)?; + ContractExecutable::Wasm(Hash(bytes.try_into().map_err(|_| anyhow!("invalid hash size"))?)) + } else if exec_type == "StellarAsset" { + ContractExecutable::StellarAsset + } else { + return Err(anyhow!("Unknown executable type")); + }; + + let storage_val = obj.get("storage").unwrap(); + let storage = if storage_val.is_null() { + None + } else { + match convert(storage_val, depth + 1)? { + ScVal::Map(Some(m)) => Some(m), + _ => return Err(anyhow!("storage must map to ScMap")), + } + }; + + Ok(ScContractInstance { executable, storage }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::decode::scval_to_json; + use serde_json::json; + + #[test] + fn test_roundtrip_primitives() { + let cases = vec![ + ScVal::Void, + ScVal::Bool(true), + ScVal::Bool(false), + ScVal::U32(42), + ScVal::I32(-7), + ScVal::U64(u64::MAX), + ScVal::I64(i64::MIN), + ScVal::String(ScString(StringM::try_from(b"hello".to_vec()).unwrap())), + ScVal::Bytes(ScBytes(vec![0xDE, 0xAD, 0xBE, 0xEF].try_into().unwrap())), + ]; + + for case in cases { + let json = scval_to_json(&case); + 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. + // Since we pass ScVal::String above, it matches exactly. + assert_eq!(parsed, case, "failed on {:?}", case); + } + } + + #[test] + fn test_lossless_map_roundtrip() { + // ScVal::U32(7) and ScVal::String("7") both stringify to the JSON key "7". + let map = ScVal::Map(Some(ScMap( + vec![ + ScMapEntry { + key: ScVal::U32(7), + val: ScVal::String(ScString(StringM::try_from(b"from_number".to_vec()).unwrap())), + }, + ScMapEntry { + key: ScVal::String(ScString(StringM::try_from(b"7".to_vec()).unwrap())), + val: ScVal::String(ScString(StringM::try_from(b"from_string".to_vec()).unwrap())), + }, + ] + .try_into() + .unwrap(), + ))); + + let json = scval_to_json(&map); + // This should be the fallback array mode + assert!(json.is_array()); + + let parsed = json_to_scval(&json).unwrap(); + assert_eq!(parsed, map); + } +} diff --git a/crates/core/src/decode/mod.rs b/crates/core/src/decode/mod.rs index 63e0c9fd..3a8cec34 100644 --- a/crates/core/src/decode/mod.rs +++ b/crates/core/src/decode/mod.rs @@ -20,6 +20,7 @@ pub mod report; pub mod resource_analyzer; pub mod return_decoder; pub mod scval_to_json; +pub mod json_to_scval; pub mod walker; pub use argument_decoder::ArgumentDecoder; @@ -37,6 +38,7 @@ pub use resource_analyzer::{ }; pub use return_decoder::ReturnValueDecoder; pub use scval_to_json::scval_to_json; +pub use json_to_scval::json_to_scval; pub use walker::{ walk_diagnostic_events, DiagnosticEventKind, DiagnosticEventWalker, StructuredDiagnosticEvent, }; From c806cb4f5ec6d4cbcc5557a66c716279c6604e6a Mon Sep 17 00:00:00 2001 From: Lewechi Date: Sun, 30 Aug 2026 14:18:55 +0100 Subject: [PATCH 2/4] fix: frix --- crates/core/src/decode/json_to_scval.rs | 63 ++++++++++++++++++------- crates/core/src/decode/mod.rs | 4 +- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/crates/core/src/decode/json_to_scval.rs b/crates/core/src/decode/json_to_scval.rs index 2522b2d5..1e66760d 100644 --- a/crates/core/src/decode/json_to_scval.rs +++ b/crates/core/src/decode/json_to_scval.rs @@ -16,7 +16,10 @@ pub fn json_to_scval(value: &Value) -> Result { fn convert(value: &Value, depth: usize) -> Result { if depth > MAX_SCVAL_DEPTH { - return Err(anyhow!("max recursion depth ({}) exceeded", MAX_SCVAL_DEPTH)); + return Err(anyhow!( + "max recursion depth ({}) exceeded", + MAX_SCVAL_DEPTH + )); } match value { @@ -40,17 +43,21 @@ fn convert(value: &Value, depth: usize) -> Result { Ok(ScVal::U64(u)) } } else { - Err(anyhow!("unsupported float or out of bounds number: {}", num)) + Err(anyhow!( + "unsupported float or out of bounds number: {}", + num + )) } } Value::String(s) => convert_string(s), Value::Array(arr) => { // Check if this is the lossless map fallback: [{"key": ..., "value": ...}, ...] - let is_lossless_map = !arr.is_empty() && arr.iter().all(|v| { - v.as_object().map_or(false, |obj| { - obj.len() == 2 && obj.contains_key("key") && obj.contains_key("value") - }) - }); + let is_lossless_map = !arr.is_empty() + && arr.iter().all(|v| { + v.as_object().map_or(false, |obj| { + obj.len() == 2 && obj.contains_key("key") && obj.contains_key("value") + }) + }); if is_lossless_map { let mut entries = Vec::with_capacity(arr.len()); @@ -185,7 +192,9 @@ fn parse_error(obj: &serde_json::Map) -> Result { match err_type { "Contract" => { - let code = code_val.as_u64().ok_or_else(|| anyhow!("Contract code must be u32"))? as u32; + let code = code_val + .as_u64() + .ok_or_else(|| anyhow!("Contract code must be u32"))? as u32; Ok(ScError::Contract(code)) } "WasmVm" => Ok(ScError::WasmVm(parse_code(code_val)?)), @@ -201,15 +210,26 @@ fn parse_error(obj: &serde_json::Map) -> Result { } } -fn parse_contract_instance(obj: &serde_json::Map, depth: usize) -> Result { - let exec_obj = obj.get("executable").and_then(|v| v.as_object()).ok_or_else(|| anyhow!("executable must be object"))?; - +fn parse_contract_instance( + obj: &serde_json::Map, + depth: usize, +) -> Result { + let exec_obj = obj + .get("executable") + .and_then(|v| v.as_object()) + .ok_or_else(|| anyhow!("executable must be object"))?; + let exec_type = exec_obj.get("type").and_then(|v| v.as_str()).unwrap_or(""); let executable = if exec_type == "Wasm" { - let hash_str = exec_obj.get("wasmHash").and_then(|v| v.as_str()).unwrap_or(""); + let hash_str = exec_obj + .get("wasmHash") + .and_then(|v| v.as_str()) + .unwrap_or(""); let hex = hash_str.strip_prefix("0x").unwrap_or(hash_str); let bytes = hex::decode(hex)?; - ContractExecutable::Wasm(Hash(bytes.try_into().map_err(|_| anyhow!("invalid hash size"))?)) + ContractExecutable::Wasm(Hash( + bytes.try_into().map_err(|_| anyhow!("invalid hash size"))?, + )) } else if exec_type == "StellarAsset" { ContractExecutable::StellarAsset } else { @@ -226,7 +246,10 @@ fn parse_contract_instance(obj: &serde_json::Map, depth: usize) - } }; - Ok(ScContractInstance { executable, storage }) + Ok(ScContractInstance { + executable, + storage, + }) } #[cfg(test)] @@ -252,7 +275,7 @@ mod tests { for case in cases { let json = scval_to_json(&case); 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. // Since we pass ScVal::String above, it matches exactly. assert_eq!(parsed, case, "failed on {:?}", case); @@ -266,11 +289,15 @@ mod tests { vec![ ScMapEntry { key: ScVal::U32(7), - val: ScVal::String(ScString(StringM::try_from(b"from_number".to_vec()).unwrap())), + val: ScVal::String(ScString( + StringM::try_from(b"from_number".to_vec()).unwrap(), + )), }, ScMapEntry { key: ScVal::String(ScString(StringM::try_from(b"7".to_vec()).unwrap())), - val: ScVal::String(ScString(StringM::try_from(b"from_string".to_vec()).unwrap())), + val: ScVal::String(ScString( + StringM::try_from(b"from_string".to_vec()).unwrap(), + )), }, ] .try_into() @@ -280,7 +307,7 @@ mod tests { let json = scval_to_json(&map); // This should be the fallback array mode assert!(json.is_array()); - + let parsed = json_to_scval(&json).unwrap(); assert_eq!(parsed, map); } diff --git a/crates/core/src/decode/mod.rs b/crates/core/src/decode/mod.rs index 3a8cec34..9fddd5d7 100644 --- a/crates/core/src/decode/mod.rs +++ b/crates/core/src/decode/mod.rs @@ -13,6 +13,7 @@ pub mod event_walker; pub mod fee_analyzer; pub mod function_call_decoder; pub mod host_error; +pub mod json_to_scval; pub mod mappings; /// Envelope-level decoding that emits one diagnostic report per operation. pub mod multi_op_decoder; @@ -20,7 +21,6 @@ pub mod report; pub mod resource_analyzer; pub mod return_decoder; pub mod scval_to_json; -pub mod json_to_scval; pub mod walker; pub use argument_decoder::ArgumentDecoder; @@ -32,13 +32,13 @@ pub use auth_address_nonce::AddressWithNonce; pub use chain_analyzer::{analyze_call_chain, CallChain, ChainAnalyzer, ChainFrame, FrameRole}; pub use deepest_error::{find_deepest_error, DeepestError, DeepestErrorFinder}; 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 resource_analyzer::{ MetricDiagnostic, MetricKind, ResourceDiagnostics, ResourceUsageAnalyzer, TransactionResultMeta, }; pub use return_decoder::ReturnValueDecoder; pub use scval_to_json::scval_to_json; -pub use json_to_scval::json_to_scval; pub use walker::{ walk_diagnostic_events, DiagnosticEventKind, DiagnosticEventWalker, StructuredDiagnosticEvent, }; From 6275f0b96550c6d69b728846ccd4f1534a07af57 Mon Sep 17 00:00:00 2001 From: Lewechi Date: Sun, 30 Aug 2026 14:25:04 +0100 Subject: [PATCH 3/4] fix: fixes --- crates/core/src/decode/json_to_scval.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/core/src/decode/json_to_scval.rs b/crates/core/src/decode/json_to_scval.rs index 1e66760d..5bbf82dc 100644 --- a/crates/core/src/decode/json_to_scval.rs +++ b/crates/core/src/decode/json_to_scval.rs @@ -2,9 +2,9 @@ use anyhow::{anyhow, Result}; use serde_json::Value; use std::str::FromStr; use stellar_xdr::curr::{ - ContractExecutable, Hash, Int128Parts, Int256Parts, ScAddress, ScBytes, ScContractInstance, - ScError, ScErrorCode, ScMap, ScMapEntry, ScNonceKey, ScString, ScSymbol, ScVal, ScVec, StringM, - UInt128Parts, UInt256Parts, + ContractExecutable, Hash, Int128Parts, ScAddress, ScBytes, ScContractInstance, + ScError, ScErrorCode, ScMap, ScMapEntry, ScNonceKey, ScString, ScVal, ScVec, StringM, + UInt128Parts, }; const MAX_SCVAL_DEPTH: usize = 100; @@ -178,7 +178,6 @@ fn parse_error(obj: &serde_json::Map) -> Result { let parse_code = |c: &Value| -> Result { let code_str = c.as_str().ok_or_else(|| anyhow!("code must be string"))?; match code_str { - "UnknownError" => Ok(ScErrorCode::UnknownError), "UnexpectedType" => Ok(ScErrorCode::UnexpectedType), "UnexpectedSize" => Ok(ScErrorCode::UnexpectedSize), "MissingValue" => Ok(ScErrorCode::MissingValue), @@ -256,7 +255,6 @@ fn parse_contract_instance( mod tests { use super::*; use crate::decode::scval_to_json; - use serde_json::json; #[test] fn test_roundtrip_primitives() { From 36b09d7b4e369ee79481dcdcf09f701a5ffcbdb6 Mon Sep 17 00:00:00 2001 From: Lewechi Date: Sun, 30 Aug 2026 14:27:20 +0100 Subject: [PATCH 4/4] done: done --- crates/core/src/decode/json_to_scval.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/core/src/decode/json_to_scval.rs b/crates/core/src/decode/json_to_scval.rs index 5bbf82dc..656455ed 100644 --- a/crates/core/src/decode/json_to_scval.rs +++ b/crates/core/src/decode/json_to_scval.rs @@ -2,9 +2,8 @@ use anyhow::{anyhow, Result}; use serde_json::Value; use std::str::FromStr; use stellar_xdr::curr::{ - ContractExecutable, Hash, Int128Parts, ScAddress, ScBytes, ScContractInstance, - ScError, ScErrorCode, ScMap, ScMapEntry, ScNonceKey, ScString, ScVal, ScVec, StringM, - UInt128Parts, + ContractExecutable, Hash, Int128Parts, ScAddress, ScBytes, ScContractInstance, ScError, + ScErrorCode, ScMap, ScMapEntry, ScNonceKey, ScString, ScVal, ScVec, StringM, UInt128Parts, }; const MAX_SCVAL_DEPTH: usize = 100;