From 5c07d9f7a7dbc4a7147c650e3ed357bacd4a4c7f Mon Sep 17 00:00:00 2001 From: Billionaire Dev <143970775+JesseJohn7@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:58:40 +0000 Subject: [PATCH 1/3] feat: add FunctionCallDecoder for Soroban host function invocations (#29) --- .../core/src/decode/function_call_decoder.rs | 106 ++++++++++++++++++ crates/core/src/decode/mod.rs | 1 + crates/core/src/error.rs | 87 +++++++------- 3 files changed, 155 insertions(+), 39 deletions(-) create mode 100644 crates/core/src/decode/function_call_decoder.rs diff --git a/crates/core/src/decode/function_call_decoder.rs b/crates/core/src/decode/function_call_decoder.rs new file mode 100644 index 00000000..0fa9cdbe --- /dev/null +++ b/crates/core/src/decode/function_call_decoder.rs @@ -0,0 +1,106 @@ +//! `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)`. +//! +//! Pipeline: +//! 1. Extract `function_name` (an `ScSymbol`) and the raw `args` (`ScVec`) +//! from the `HostFunction` invocation. +//! 2. Ask `SpecParser` for the contract's `ContractFunction` metadata that +//! matches `function_name`. +//! 3. Zip the function's expected parameter list against the raw argument +//! array, position by position. +//! 4. For each pair, run the raw `ScVal` through the SCVal-to-JSON +//! converter and attach the expected parameter's name to the result. +//! 5. If the argument count doesn't match the expected parameter count, +//! stop and return `GratError::ArityMismatch` with enough detail to +//! pinpoint the mismatch. + +use crate::error::GratError; +use crate::spec::SpecParser; +use crate::decode::scval_json::scval_to_json; // adjust path if the converter lives elsewhere + +use soroban_env_host::xdr::{HostFunction, InvokeContractArgs, ScVal}; +use serde::Serialize; +use serde_json::Value as JsonValue; + +/// 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, + } + + /// 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, + } + + pub struct FunctionCallDecoder; + + impl FunctionCallDecoder { + /// Decode a raw `HostFunction` invocation into a labeled function-call + /// summary, using `spec_parser` to resolve the target contract's + /// function signature. + /// + /// # Errors + /// - `GratError::ArityMismatch` if the number of raw arguments doesn't + /// match the number of parameters declared for this function. + /// - Propagates whatever `GratError` variant `SpecParser` returns if the + /// function name can't be resolved against the contract's spec. + pub fn decode( + host_function: &HostFunction, + spec_parser: &SpecParser, + ) -> 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 = invoke_args.function_name.to_string(); + let raw_args: &[ScVal] = invoke_args.args.as_slice(); + + // Ask the spec for this function's expected signature. + let contract_function = spec_parser.get_function(&function_name)?; + let expected_params = &contract_function.parameters; + + // 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, raw_val)| DecodedArgument { + name: param.name.clone(), + value: scval_to_json(raw_val), + }) + .collect(); + + Ok(DecodedFunctionCall { + function_name, + arguments, + }) + } + } \ No newline at end of file diff --git a/crates/core/src/decode/mod.rs b/crates/core/src/decode/mod.rs index f34d32ad..66092893 100644 --- a/crates/core/src/decode/mod.rs +++ b/crates/core/src/decode/mod.rs @@ -10,6 +10,7 @@ pub mod host_error; pub mod mappings; pub mod report; pub mod walker; +pub mod function_call_decoder; pub use auth::{ AddressCredential, AuthChain, AuthCredential, AuthFunctionKind, AuthInvocation, diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index bc83c1e7..5bed96b2 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -27,62 +27,71 @@ pub enum ArchiveErrorKind { #[error("decompression failed for '{file}': {reason}")] DecompressionFailed { file: String, reason: String }, } - #[derive(Debug, Error)] pub enum GratError { #[error("RPC request timed out after {timeout_secs}s (method: {method})")] - NetworkTimeout { method: String, timeout_secs: u64 }, + NetworkTimeout { method: String, timeout_secs: u64 }, - #[error("RPC error: {0}")] - RpcError(String), + #[error("RPC error: {0}")] + RpcError(String), - #[error("JSON-RPC error: {0}")] - JsonRpc(JsonRpcError), + #[error("JSON-RPC error: {0}")] + JsonRpc(JsonRpcError), - #[error("Archive error: {0}")] - ArchiveError(#[from] ArchiveErrorKind), + #[error("Archive error: {0}")] + ArchiveError(#[from] ArchiveErrorKind), - #[error("XDR error: {0}")] - XdrError(String), + #[error("XDR error: {0}")] + XdrError(String), - #[error("XDR decoding failed for {type_name}: {reason}")] - XdrDecodingFailed { - type_name: &'static str, - reason: String, - }, + #[error("XDR decoding failed for {type_name}: {reason}")] + XdrDecodingFailed { + type_name: &'static str, + reason: String, + }, - #[error("Spec error: {0}")] - SpecError(String), + #[error("Spec error: {0}")] + SpecError(String), - #[error("Cache error: {0}")] - CacheError(String), + #[error("Cache error: {0}")] + CacheError(String), - #[error("Taxonomy error: {0}")] - TaxonomyError(String), + #[error("Taxonomy error: {0}")] + TaxonomyError(String), - #[error("Replay error: {0}")] - ReplayError(String), + #[error("Replay error: {0}")] + ReplayError(String), - #[error("Transaction not found: {0}")] - TransactionNotFound(String), + #[error("Transaction not found: {0}")] + TransactionNotFound(String), - #[error("Contract not found: {0}")] - ContractNotFound(String), + #[error("Contract not found: {0}")] + ContractNotFound(String), - #[error("Config error: {0}")] - ConfigError(String), + #[error("Config error: {0}")] + ConfigError(String), - #[error("Invalid address: {0}")] - InvalidAddress(String), + #[error("Invalid address: {0}")] + InvalidAddress(String), - #[error("Internal error: {0}")] - Internal(String), + #[error("Internal error: {0}")] + Internal(String), - #[error("Not a Soroban transaction: no InvokeHostFunction operation found")] - NotSorobanTransaction, + #[error("Not a Soroban transaction: no InvokeHostFunction operation found")] + NotSorobanTransaction, - #[error("Transaction succeeded — no error to decode")] - TransactionSucceeded, -} + #[error("Transaction succeeded — no error to decode")] + TransactionSucceeded, + + #[error("Function '{function_name}' expects {expected} argument(s), but {actual} were provided")] + ArityMismatch { + function_name: String, + expected: usize, + actual: usize, + }, + + #[error("Unsupported host function: {0}")] + UnsupportedHostFunction(String), + } -pub type GratResult = Result; + pub type GratResult = Result; \ No newline at end of file From 5ef35fc5946dd11775d78534e3eacf8cc6a5e6b6 Mon Sep 17 00:00:00 2001 From: JesseJohn7 Date: Tue, 21 Jul 2026 12:38:12 +0100 Subject: [PATCH 2/3] Fix FunctionCallDecoder to match real ContractSpec/GratError types --- .../core/src/decode/function_call_decoder.rs | 166 ++++++++++-------- 1 file changed, 89 insertions(+), 77 deletions(-) diff --git a/crates/core/src/decode/function_call_decoder.rs b/crates/core/src/decode/function_call_decoder.rs index 0fa9cdbe..02e80bd1 100644 --- a/crates/core/src/decode/function_call_decoder.rs +++ b/crates/core/src/decode/function_call_decoder.rs @@ -8,25 +8,12 @@ //! `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)`. -//! -//! Pipeline: -//! 1. Extract `function_name` (an `ScSymbol`) and the raw `args` (`ScVec`) -//! from the `HostFunction` invocation. -//! 2. Ask `SpecParser` for the contract's `ContractFunction` metadata that -//! matches `function_name`. -//! 3. Zip the function's expected parameter list against the raw argument -//! array, position by position. -//! 4. For each pair, run the raw `ScVal` through the SCVal-to-JSON -//! converter and attach the expected parameter's name to the result. -//! 5. If the argument count doesn't match the expected parameter count, -//! stop and return `GratError::ArityMismatch` with enough detail to -//! pinpoint the mismatch. use crate::error::GratError; -use crate::spec::SpecParser; -use crate::decode::scval_json::scval_to_json; // adjust path if the converter lives elsewhere +use crate::spec::ContractSpec; +use crate::decode::scval_to_json::scval_to_json; -use soroban_env_host::xdr::{HostFunction, InvokeContractArgs, ScVal}; +use stellar_xdr::curr::{HostFunction, InvokeContractArgs, ScVal}; use serde::Serialize; use serde_json::Value as JsonValue; @@ -35,72 +22,97 @@ use serde_json::Value as JsonValue; #[derive(Debug, Clone, Serialize)] pub struct DecodedArgument { pub name: String, - pub value: JsonValue, - } + pub value: JsonValue, +} + +/// 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) + } +} - /// 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, - } +pub struct FunctionCallDecoder; - 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:?}" + ))); + } + }; - impl FunctionCallDecoder { - /// Decode a raw `HostFunction` invocation into a labeled function-call - /// summary, using `spec_parser` to resolve the target contract's - /// function signature. - /// - /// # Errors - /// - `GratError::ArityMismatch` if the number of raw arguments doesn't - /// match the number of parameters declared for this function. - /// - Propagates whatever `GratError` variant `SpecParser` returns if the - /// function name can't be resolved against the contract's spec. - pub fn decode( - host_function: &HostFunction, - spec_parser: &SpecParser, - ) -> 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(); - let function_name = invoke_args.function_name.to_string(); - 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" + )) + })?; - // Ask the spec for this function's expected signature. - let contract_function = spec_parser.get_function(&function_name)?; - let expected_params = &contract_function.parameters; + 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(), - }); - } + // 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, raw_val)| DecodedArgument { - name: param.name.clone(), - value: scval_to_json(raw_val), - }) - .collect(); + 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, - }) - } - } \ No newline at end of file + Ok(DecodedFunctionCall { + function_name, + arguments, + }) + } +} \ No newline at end of file From eb74d0818dd73db641218c569aab90db6408a6c3 Mon Sep 17 00:00:00 2001 From: JesseJohn7 Date: Tue, 21 Jul 2026 12:50:11 +0100 Subject: [PATCH 3/3] fmt: fix formatting to satisfy cargo fmt --check --- .../core/src/decode/function_call_decoder.rs | 4 +- crates/core/src/decode/mod.rs | 3 +- crates/core/src/error.rs | 77 ++++++++++--------- 3 files changed, 43 insertions(+), 41 deletions(-) diff --git a/crates/core/src/decode/function_call_decoder.rs b/crates/core/src/decode/function_call_decoder.rs index 02e80bd1..0673b3a3 100644 --- a/crates/core/src/decode/function_call_decoder.rs +++ b/crates/core/src/decode/function_call_decoder.rs @@ -9,13 +9,13 @@ //! 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 crate::decode::scval_to_json::scval_to_json; -use stellar_xdr::curr::{HostFunction, InvokeContractArgs, ScVal}; 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. diff --git a/crates/core/src/decode/mod.rs b/crates/core/src/decode/mod.rs index a0e7502a..9ea4acd5 100644 --- a/crates/core/src/decode/mod.rs +++ b/crates/core/src/decode/mod.rs @@ -7,12 +7,13 @@ pub mod cross_contract; pub mod decode_context; pub mod diagnostic; pub mod event_walker; +pub mod function_call_decoder; pub mod host_error; pub mod mappings; pub mod report; pub mod scval_to_json; pub mod walker; -pub mod function_call_decoder; + pub use auth::{ AddressCredential, AuthChain, AuthCredential, AuthFunctionKind, AuthInvocation, diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index a317958e..9e90c3c5 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -30,63 +30,65 @@ pub enum ArchiveErrorKind { #[derive(Debug, Error)] pub enum GratError { #[error("RPC request timed out after {timeout_secs}s (method: {method})")] - NetworkTimeout { method: String, timeout_secs: u64 }, + NetworkTimeout { method: String, timeout_secs: u64 }, - #[error("RPC error: {0}")] - RpcError(String), + #[error("RPC error: {0}")] + RpcError(String), - #[error("JSON-RPC error: {0}")] - JsonRpc(JsonRpcError), + #[error("JSON-RPC error: {0}")] + JsonRpc(JsonRpcError), - #[error("Archive error: {0}")] - ArchiveError(#[from] ArchiveErrorKind), + #[error("Archive error: {0}")] + ArchiveError(#[from] ArchiveErrorKind), - #[error("XDR error: {0}")] - XdrError(String), + #[error("XDR error: {0}")] + XdrError(String), - #[error("XDR decoding failed for {type_name}: {reason}")] - XdrDecodingFailed { - type_name: &'static str, - reason: String, - }, + #[error("XDR decoding failed for {type_name}: {reason}")] + XdrDecodingFailed { + type_name: &'static str, + reason: String, + }, - #[error("Spec error: {0}")] - SpecError(String), + #[error("Spec error: {0}")] + SpecError(String), - #[error("Cache error: {0}")] - CacheError(String), + #[error("Cache error: {0}")] + CacheError(String), - #[error("Taxonomy error: {0}")] - TaxonomyError(String), + #[error("Taxonomy error: {0}")] + TaxonomyError(String), - #[error("Replay error: {0}")] - ReplayError(String), + #[error("Replay error: {0}")] + ReplayError(String), - #[error("Transaction not found: {0}")] - TransactionNotFound(String), + #[error("Transaction not found: {0}")] + TransactionNotFound(String), - #[error("Contract not found: {0}")] - ContractNotFound(String), + #[error("Contract not found: {0}")] + ContractNotFound(String), - #[error("Config error: {0}")] - ConfigError(String), + #[error("Config error: {0}")] + ConfigError(String), - #[error("Invalid address: {0}")] - InvalidAddress(String), + #[error("Invalid address: {0}")] + InvalidAddress(String), - #[error("Internal error: {0}")] - Internal(String), + #[error("Internal error: {0}")] + Internal(String), - #[error("Not a Soroban transaction: no InvokeHostFunction operation found")] - NotSorobanTransaction, + #[error("Not a Soroban transaction: no InvokeHostFunction operation found")] + NotSorobanTransaction, - #[error("Transaction succeeded — no error to decode")] + #[error("Transaction succeeded — no error to decode")] TransactionSucceeded, #[error("Invalid Contract ID: {0}")] InvalidContractId(String), - #[error("Function '{function_name}' expects {expected} argument(s), but {actual} were provided")] + #[error( + "Function '{function_name}' expects {expected} argument(s), but {actual} were provided" + )] ArityMismatch { function_name: String, expected: usize, @@ -97,5 +99,4 @@ pub enum GratError { UnsupportedHostFunction(String), } - - pub type GratResult = Result; \ No newline at end of file +pub type GratResult = Result; \ No newline at end of file