diff --git a/.gitattributes b/.gitattributes index dfe07704..0bc8f0c7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,6 @@ # Auto detect text files and perform LF normalization * text=auto +# Force LF endings for Rust code and common text files +*.rs text eol=lf +*.toml text eol=lf +*.md text eol=lf \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index bfccda13..fd9acfe3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ description = "Soroban Transaction Debugger — From cryptic error to root cause [workspace.dependencies] # Stellar / Soroban — Critical Path -stellar-xdr = { version = "=21.2.0", features = ["std", "serde"] } +stellar-xdr = { version = "=21.2.0", features = ["std", "serde", "base64"] } stellar-strkey = "0.0.9" soroban-env-host = "=21.2.0" soroban-spec = "=21.2.0" diff --git a/crates/core/src/network/config.rs b/crates/core/src/network/config.rs index e644cfc2..82b7ace2 100644 --- a/crates/core/src/network/config.rs +++ b/crates/core/src/network/config.rs @@ -27,18 +27,16 @@ pub fn default_network() -> NetworkConfig { } /// Validate that a network configuration is reachable. +/// +/// Uses the timeout from [`NetworkConfig::request_timeout_secs`] so a +/// misconfigured or unreachable endpoint does not block the caller +/// indefinitely. pub async fn validate_network(config: &NetworkConfig) -> bool { - let client = reqwest::Client::new(); - client - .post(&config.rpc_url) - .json(&serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "getHealth", - "params": {} - })) - .send() + let timeout = Duration::from_secs(config.request_timeout_secs); + let transport = JsonRpcTransport::new(&config.rpc_url, 0, timeout); + let req = JsonRpcRequest::new(1, "getHealth", GetHealthParams {}); + transport + .call::<_, serde_json::Value>(&req) .await - .map(|r| r.status().is_success()) - .unwrap_or(false) + .is_ok() } diff --git a/crates/core/src/network/jsonrpc.rs b/crates/core/src/network/jsonrpc.rs new file mode 100644 index 00000000..6bd31d39 --- /dev/null +++ b/crates/core/src/network/jsonrpc.rs @@ -0,0 +1,413 @@ +//! Generic JSON-RPC 2.0 client primitives. +//! +//! Provides strongly-typed request/response envelopes and a reusable HTTP +//! transport so every RPC call is validated at compile time via Serde. +//! +//! # Timeouts +//! +//! [`JsonRpcTransport`] enforces a per-attempt wall-clock timeout via +//! [`tokio::time::timeout`]. The timeout covers the full round-trip: TCP +//! connect, request send, and response body read. If any attempt exceeds the +//! deadline, the future is cancelled and +//! [`PrismError::NetworkTimeout`] is returned immediately — no thread is +//! blocked and no resource is leaked. +//! +//! The default is [`crate::types::config::DEFAULT_REQUEST_TIMEOUT_SECS`] +//! (30 s). Pass a custom [`Duration`] to [`JsonRpcTransport::new`] to +//! override it. + +use crate::types::error::{PrismError, PrismResult}; +use serde::{Deserialize, Serialize}; +use std::time::{Duration, Instant}; + +// ── Wire types ──────────────────────────────────────────────────────────────── + +/// JSON-RPC 2.0 request envelope. +/// +/// `T` is the method-specific params struct; it must implement [`Serialize`]. +#[derive(Debug, Serialize)] +pub struct JsonRpcRequest { + pub jsonrpc: &'static str, + pub id: u64, + pub method: &'static str, + pub params: T, +} + +impl JsonRpcRequest { + /// Construct a request with the standard `"2.0"` version string. + pub fn new(id: u64, method: &'static str, params: T) -> Self { + Self { jsonrpc: "2.0", id, method, params } + } +} + +/// JSON-RPC 2.0 response envelope. +/// +/// `T` is the method-specific result struct; it must implement [`Deserialize`]. +#[derive(Debug, Deserialize)] +pub struct JsonRpcResponse { + #[allow(dead_code)] + pub jsonrpc: String, + #[allow(dead_code)] + pub id: u64, + pub result: Option, + pub error: Option, +} + +/// JSON-RPC error object returned inside a response. +#[derive(Debug, Deserialize)] +pub struct JsonRpcError { + pub code: i64, + pub message: String, +} + +// ── Soroban RPC param/result types ─────────────────────────────────────────── + +/// Params for `getTransaction`. +#[derive(Debug, Serialize)] +pub struct GetTransactionParams { + pub hash: String, +} + +/// Params for `simulateTransaction`. +#[derive(Debug, Serialize)] +pub struct SimulateTransactionParams { + pub transaction: String, +} + +/// Params for `getLedgerEntries`. +#[derive(Debug, Serialize)] +pub struct GetLedgerEntriesParams { + pub keys: Vec, +} + +/// Params for `getEvents`. +#[derive(Debug, Serialize)] +pub struct GetEventsParams { + #[serde(rename = "startLedger")] + pub start_ledger: u32, + pub filters: serde_json::Value, +} + +/// Params for `getLatestLedger` — the method takes no parameters. +#[derive(Debug, Serialize)] +pub struct EmptyParams {} + +/// Params for `getHealth` — the method takes no parameters. +pub type GetHealthParams = EmptyParams; + +// ── Transport ───────────────────────────────────────────────────────────────── + +/// Low-level JSON-RPC HTTP transport. +/// +/// Handles serialization, deserialization, per-attempt timeout, retry, and +/// rate-limit backoff. Higher-level clients (e.g. [`super::rpc::RpcClient`]) +/// build on top of this. +/// +/// # Timeout behaviour +/// +/// Each attempt (send + body read) is wrapped in [`tokio::time::timeout`]. +/// Exceeding the deadline cancels the in-flight future and returns +/// [`PrismError::NetworkTimeout`] — the retry loop then treats it like any +/// other transient failure and backs off before the next attempt. +pub struct JsonRpcTransport { + /// Underlying HTTP client. No reqwest-level timeout is set; the async + /// timeout in [`Self::call`] is the authoritative deadline. + client: reqwest::Client, + endpoint: String, + max_retries: u32, + /// Per-attempt timeout applied to the full send + body-read round-trip. + timeout: Duration, +} + +impl JsonRpcTransport { + /// Create a transport pointed at `endpoint`. + /// + /// - `max_retries`: number of additional attempts after the first failure. + /// - `timeout`: per-attempt deadline; use + /// [`Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS)`] for the + /// standard 30 s default. + pub fn new(endpoint: impl Into, max_retries: u32, timeout: Duration) -> Self { + Self { + // No reqwest-level timeout — tokio::time::timeout owns the deadline. + client: reqwest::Client::builder() + .build() + .expect("failed to build HTTP client"), + endpoint: endpoint.into(), + max_retries, + timeout, + } + } + + /// Execute a typed JSON-RPC call and return the typed result. + /// + /// Each attempt is bounded by `self.timeout`. On expiry the attempt is + /// cancelled and [`PrismError::NetworkTimeout`] is stored as the last + /// error. Retries use exponential backoff starting at 100 ms. HTTP 429 + /// responses are also retried. + pub async fn call(&self, request: &JsonRpcRequest

) -> PrismResult + where + P: Serialize + std::fmt::Debug, + R: for<'de> Deserialize<'de>, + { + let method = request.method; + let timeout_secs = self.timeout.as_secs(); + let mut last_error: Option = None; + + for attempt in 0..=self.max_retries { + if attempt > 0 { + let backoff = Duration::from_millis(100 * 2u64.pow(attempt)); + tokio::time::sleep(backoff).await; + tracing::debug!(attempt, method, "retrying RPC request"); + } + + let started_at = Instant::now(); + tracing::debug!( + method, + endpoint = %self.endpoint, + attempt, + timeout_secs, + "sending RPC request" + ); + + // Wrap the full send + body-read in a single timeout future so a + // stalled connection cannot block the task indefinitely. + let attempt_result = tokio::time::timeout( + self.timeout, + self.send_and_read(request), + ) + .await; + + match attempt_result { + // Timeout fired — cancel this attempt and record the error. + Err(_elapsed) => { + let elapsed_ms = started_at.elapsed().as_millis(); + tracing::warn!( + method, + endpoint = %self.endpoint, + attempt, + elapsed_ms, + timeout_secs, + "RPC request timed out" + ); + last_error = Some(PrismError::NetworkTimeout { + method: method.to_string(), + timeout_secs, + }); + } + + // Request completed within the deadline. + Ok(Ok((status, body))) => { + let elapsed_ms = started_at.elapsed().as_millis(); + tracing::debug!( + method, + endpoint = %self.endpoint, + attempt, + status = %status, + elapsed_ms, + "RPC response received" + ); + tracing::trace!( + method, + elapsed_ms, + response = %body, + "RPC response payload" + ); + + if status == 429 { + tracing::warn!("rate limited by RPC endpoint, backing off"); + last_error = Some(PrismError::RpcError("rate limited".to_string())); + continue; + } + + let envelope: JsonRpcResponse = + serde_json::from_str(&body).map_err(|e| { + PrismError::RpcError(format!("response parse error: {e}")) + })?; + + if let Some(err) = envelope.error { + tracing::debug!( + method, + endpoint = %self.endpoint, + error = %err.message, + "RPC returned error response" + ); + return Err(PrismError::RpcError(err.message)); + } + + return envelope + .result + .ok_or_else(|| PrismError::RpcError("empty result".to_string())); + } + + // Network / transport error. + Ok(Err(e)) => { + tracing::debug!( + method, + endpoint = %self.endpoint, + attempt, + elapsed_ms = started_at.elapsed().as_millis(), + error = %e, + "RPC request failed" + ); + last_error = Some(PrismError::RpcError(format!("request failed: {e}"))); + } + } + } + + Err(last_error.unwrap_or_else(|| PrismError::RpcError("unknown error".to_string()))) + } + + /// Send the request and read the full response body. + /// + /// Returns `(status_code, body_text)`. Extracted so the timeout future + /// has a clean boundary to cancel. + async fn send_and_read( + &self, + request: &JsonRpcRequest

, + ) -> Result<(u16, String), String> { + let response = self + .client + .post(&self.endpoint) + .json(request) + .send() + .await + .map_err(|e| e.to_string())?; + + let status = response.status().as_u16(); + let body = response.text().await.map_err(|e| e.to_string())?; + Ok((status, body)) + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::config::{NetworkConfig, DEFAULT_REQUEST_TIMEOUT_SECS}; + + // ── Config / default timeout ────────────────────────────────────────────── + + #[test] + fn default_timeout_is_30s() { + let cfg = NetworkConfig::testnet(); + assert_eq!(cfg.request_timeout_secs, DEFAULT_REQUEST_TIMEOUT_SECS); + assert_eq!(cfg.request_timeout_secs, 30); + } + + #[test] + fn custom_timeout_round_trips_through_serde() { + let mut cfg = NetworkConfig::testnet(); + cfg.request_timeout_secs = 5; + + let json = serde_json::to_string(&cfg).expect("serialize"); + let decoded: NetworkConfig = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(decoded.request_timeout_secs, 5); + } + + #[test] + fn missing_timeout_field_deserializes_to_default() { + // Simulate a config file written before the timeout field existed. + let json = r#"{ + "network": "testnet", + "rpc_url": "https://soroban-testnet.stellar.org", + "network_passphrase": "Test SDF Network ; September 2015", + "archive_urls": [] + }"#; + let cfg: NetworkConfig = serde_json::from_str(json).expect("deserialize"); + assert_eq!(cfg.request_timeout_secs, DEFAULT_REQUEST_TIMEOUT_SECS); + } + + // ── Timeout fires on a stalled connection ───────────────────────────────── + + /// Verifies that a transport with a 1 s timeout returns `NetworkTimeout` + /// when the server never responds. Uses `tokio::time::pause` so the test + /// completes instantly without real wall-clock delay. + #[tokio::test] + async fn timeout_fires_on_stalled_server() { + // Bind a TCP listener that accepts connections but never writes back. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().expect("local_addr"); + + // Accept the connection silently so the client doesn't get a + // connection-refused error — it just hangs waiting for a response. + tokio::spawn(async move { + if let Ok((_stream, _)) = listener.accept().await { + // Hold the stream open indefinitely to simulate a stall. + tokio::time::sleep(Duration::from_secs(3600)).await; + } + }); + + tokio::time::pause(); + + let transport = JsonRpcTransport::new( + format!("http://{addr}"), + 0, // no retries — we want exactly one attempt + Duration::from_secs(1), + ); + let req = JsonRpcRequest::new(1, "getLatestLedger", EmptyParams {}); + + // Advance mock time past the 1 s deadline. + let call_fut = transport.call::<_, serde_json::Value>(&req); + tokio::pin!(call_fut); + + // Poll once to start the future, then advance time. + let result = tokio::select! { + res = &mut call_fut => res, + _ = async { + tokio::time::advance(Duration::from_secs(2)).await; + } => { + call_fut.await + } + }; + + tokio::time::resume(); + + match result { + Err(PrismError::NetworkTimeout { method, timeout_secs }) => { + assert_eq!(method, "getLatestLedger"); + assert_eq!(timeout_secs, 1); + } + other => panic!("expected NetworkTimeout, got {other:?}"), + } + } + + /// Verifies that a successful (fast) response is not affected by the + /// timeout machinery. + #[tokio::test] + async fn successful_response_not_affected_by_timeout() { + use std::io::Write; + + // Minimal HTTP/1.1 response with a valid JSON-RPC body. + let body = r#"{"jsonrpc":"2.0","id":1,"result":{"sequence":100}}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("local_addr"); + + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + // Drain the request. + let mut buf = [0u8; 4096]; + let _ = std::io::Read::read(&mut stream, &mut buf); + let _ = stream.write_all(response.as_bytes()); + } + }); + + let transport = JsonRpcTransport::new( + format!("http://{addr}"), + 0, + Duration::from_secs(5), + ); + let req = JsonRpcRequest::new(1, "getLatestLedger", EmptyParams {}); + let result = transport.call::<_, serde_json::Value>(&req).await; + + assert!(result.is_ok(), "expected Ok, got {result:?}"); + } +} diff --git a/crates/core/src/network/mod.rs b/crates/core/src/network/mod.rs index 2f5e2a42..cc6de28c 100644 --- a/crates/core/src/network/mod.rs +++ b/crates/core/src/network/mod.rs @@ -2,4 +2,5 @@ pub mod archive; pub mod config; +pub mod jsonrpc; pub mod rpc; diff --git a/crates/core/src/network/rpc.rs b/crates/core/src/network/rpc.rs index 4da43453..0680e268 100644 --- a/crates/core/src/network/rpc.rs +++ b/crates/core/src/network/rpc.rs @@ -2,8 +2,12 @@ //! //! Communicates with Soroban RPC endpoints: `getTransaction`, `simulateTransaction`, //! `getLedgerEntries`, `getEvents`, `getLatestLedger`. Handles pagination, retries, -//! and rate limit backoff. +//! and rate-limit backoff via [`super::jsonrpc::JsonRpcTransport`]. +use crate::network::jsonrpc::{ + EmptyParams, GetEventsParams, GetLedgerEntriesParams, GetTransactionParams, + JsonRpcRequest, JsonRpcTransport, SimulateTransactionParams, +}; use crate::types::config::NetworkConfig; use crate::types::error::{PrismError, PrismResult}; use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE}; diff --git a/crates/core/src/types/config.rs b/crates/core/src/types/config.rs index d6f247b8..25025055 100644 --- a/crates/core/src/types/config.rs +++ b/crates/core/src/types/config.rs @@ -44,6 +44,21 @@ pub struct NetworkConfig { pub network_passphrase: String, /// History archive URL(s). pub archive_urls: Vec, + /// Per-request timeout in seconds for all RPC calls. + /// + /// Any request that does not receive a complete response within this + /// window is cancelled and returns [`crate::types::error::PrismError::NetworkTimeout`]. + /// Defaults to [`DEFAULT_REQUEST_TIMEOUT_SECS`] (30 s) when deserializing + /// configs that do not specify this field. + #[serde(default = "default_request_timeout_secs")] + pub request_timeout_secs: u64, +} + +/// Default per-request timeout: 30 seconds. +pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30; + +fn default_request_timeout_secs() -> u64 { + DEFAULT_REQUEST_TIMEOUT_SECS } impl NetworkConfig { @@ -56,6 +71,7 @@ impl NetworkConfig { archive_urls: vec![ "https://history.stellar.org/prd/core-testnet/core_testnet_001".to_string(), ], + request_timeout_secs: DEFAULT_REQUEST_TIMEOUT_SECS, } } @@ -68,6 +84,7 @@ impl NetworkConfig { archive_urls: vec![ "https://history.stellar.org/prd/core-live/core_live_001".to_string() ], + request_timeout_secs: DEFAULT_REQUEST_TIMEOUT_SECS, } } @@ -78,6 +95,7 @@ impl NetworkConfig { rpc_url: "https://rpc-futurenet.stellar.org".to_string(), network_passphrase: "Test SDF Future Network ; October 2022".to_string(), archive_urls: vec!["https://history-futurenet.stellar.org".to_string()], + request_timeout_secs: DEFAULT_REQUEST_TIMEOUT_SECS, } } @@ -88,6 +106,7 @@ impl NetworkConfig { rpc_url: rpc_url.to_string(), network_passphrase: passphrase.to_string(), archive_urls: Vec::new(), + request_timeout_secs: DEFAULT_REQUEST_TIMEOUT_SECS, } } } diff --git a/crates/core/src/types/error.rs b/crates/core/src/types/error.rs index cbd3fc3b..5fb00a70 100644 --- a/crates/core/src/types/error.rs +++ b/crates/core/src/types/error.rs @@ -5,12 +5,19 @@ use std::fmt; /// Top-level error type for all Prism operations. #[derive(Debug)] pub enum PrismError { + /// A network request exceeded the configured timeout duration. + NetworkTimeout { method: String, timeout_secs: u64 }, /// Error communicating with the Soroban RPC endpoint. RpcError(String), /// Error fetching or parsing history archive data. ArchiveError(String), /// Error decoding XDR data. XdrError(String), + /// XDR base64 decoding failed for a specific type. + /// + /// Returned by [`crate::xdr::codec::XdrCodec::from_xdr_base64`] when the + /// input is malformed or does not match the expected XDR type. + XdrDecodingFailed { type_name: &'static str, reason: String }, /// Error parsing WASM or contract spec data. SpecError(String), /// Error in the local cache layer. @@ -34,9 +41,15 @@ pub enum PrismError { impl fmt::Display for PrismError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::NetworkTimeout { method, timeout_secs } => { + write!(f, "RPC request timed out after {timeout_secs}s (method: {method})") + } Self::RpcError(msg) => write!(f, "RPC error: {msg}"), Self::ArchiveError(msg) => write!(f, "Archive error: {msg}"), Self::XdrError(msg) => write!(f, "XDR error: {msg}"), + Self::XdrDecodingFailed { type_name, reason } => { + write!(f, "XDR decoding failed for {type_name}: {reason}") + } Self::SpecError(msg) => write!(f, "Spec error: {msg}"), Self::CacheError(msg) => write!(f, "Cache error: {msg}"), Self::TaxonomyError(msg) => write!(f, "Taxonomy error: {msg}"), diff --git a/crates/core/src/xdr/codec.rs b/crates/core/src/xdr/codec.rs index 51c5cd0b..95f7bd2b 100644 --- a/crates/core/src/xdr/codec.rs +++ b/crates/core/src/xdr/codec.rs @@ -2,17 +2,154 @@ //! //! Handles serialization/deserialization of transaction envelopes, results, //! ledger entries, SCVal, and SCSpecEntry types. +//! +//! # `XdrCodec` trait +//! +//! [`XdrCodec`] provides a uniform `from_xdr_base64` / `to_xdr_base64` +//! interface for any Stellar XDR type. Implementations delegate directly to +//! the `stellar_xdr::next::{ReadXdr, WriteXdr}` traits so the codec layer +//! adds no extra copies or allocations. +//! +//! Malformed input returns [`PrismError::XdrDecodingFailed`] — never a panic. + +use crate::types::error::{PrismError, PrismResult}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use stellar_xdr::next::{Limits, ReadXdr, TransactionMeta, TransactionResult, WriteXdr}; + +// ── XdrCodec trait ──────────────────────────────────────────────────────────── + +/// Uniform base64-XDR encode/decode interface for Stellar XDR types. +/// +/// # Default implementation +/// +/// There is no blanket implementation — each type gets an explicit `impl` so +/// the compiler can catch mismatches between the type and the XDR schema at +/// compile time rather than at runtime. +pub trait XdrCodec: Sized { + /// The short name used in error messages (e.g. `"TransactionMeta"`). + const TYPE_NAME: &'static str; + + /// Decode a base64-encoded XDR string into `Self`. + /// + /// Returns [`PrismError::XdrDecodingFailed`] if the input is not valid + /// base64, not valid XDR for this type, or contains trailing bytes. + fn from_xdr_base64(b64: &str) -> PrismResult; + + /// Encode `self` back to a base64 XDR string. + /// + /// Useful for round-trip testing and caching decoded values. + fn to_xdr_base64(&self) -> PrismResult; +} use crate::types::error::{PrismError, PrismResult}; use base64::{engine::general_purpose::STANDARD, Engine as _}; -/// Decode a base64-encoded XDR transaction result. +/// Decode / encode [`TransactionMeta`] XDR. +/// +/// `TransactionMeta` is a union with four variants: +/// +/// | Variant | Contents | +/// |---------|----------| +/// | `V0` | `VecM` | +/// | `V1` | `TransactionMetaV1` | +/// | `V2` | `TransactionMetaV2` | +/// | `V3` | `TransactionMetaV3` — Soroban; contains `soroban_meta` with `events` and `diagnostic_events` | +/// +/// The indexer should match on the returned enum to access version-specific +/// fields: +/// +/// ```rust,ignore +/// use stellar_xdr::next::TransactionMeta; +/// use prism_core::xdr::codec::XdrCodec; +/// +/// let meta = TransactionMeta::from_xdr_base64(raw_b64)?; +/// if let TransactionMeta::V3(v3) = &meta { +/// if let Some(soroban) = &v3.soroban_meta { +/// let event_count = soroban.events.len(); +/// } +/// } +/// ``` +impl XdrCodec for TransactionMeta { + const TYPE_NAME: &'static str = "TransactionMeta"; + + fn from_xdr_base64(b64: &str) -> PrismResult { + TransactionMeta::from_xdr_base64(b64, Limits::none()).map_err(|e| { + PrismError::XdrDecodingFailed { + type_name: Self::TYPE_NAME, + reason: e.to_string(), + } + }) + } + + fn to_xdr_base64(&self) -> PrismResult { + self.to_xdr_base64(Limits::none()).map_err(|e| { + PrismError::XdrDecodingFailed { + type_name: Self::TYPE_NAME, + reason: e.to_string(), + } + }) + } +} + +// ── TransactionResult ───────────────────────────────────────────────────────── + +/// Decode / encode [`TransactionResult`] XDR. +/// +/// `TransactionResult` is a struct wrapping: +/// - `fee_charged: i64` — actual fee deducted from the source account +/// - `result: TransactionResultResult` — the outcome union; key variants: /// -/// # Arguments -/// * `xdr_base64` - Base64-encoded XDR string +/// | Variant | Meaning | +/// |---------|---------| +/// | `TxSuccess(ops)` | All operations succeeded; `ops` holds per-op results | +/// | `TxFailed(ops)` | One or more operations failed; `ops` holds per-op results | +/// | `TxInsufficientBalance` | Fee would drop account below minimum reserve | +/// | `TxBadSeq` | Sequence number mismatch | +/// | `TxInsufficientFee` | Submitted fee too low | +/// | `TxSorobanInvalid` | Soroban-specific precondition not met | +/// | *(other void variants)* | See [`stellar_xdr::next::TransactionResultResult`] | /// -/// # Returns -/// The raw decoded bytes, ready for further parsing. +/// # Example +/// +/// ```rust,ignore +/// use stellar_xdr::next::{TransactionResult, TransactionResultResult}; +/// use prism_core::xdr::codec::XdrCodec; +/// +/// let result = TransactionResult::from_xdr_base64(raw_b64)?; +/// match &result.result { +/// TransactionResultResult::TxSuccess(ops) => { /* index ops */ } +/// TransactionResultResult::TxInsufficientBalance => { /* surface error */ } +/// _ => {} +/// } +/// ``` +impl XdrCodec for TransactionResult { + const TYPE_NAME: &'static str = "TransactionResult"; + + fn from_xdr_base64(b64: &str) -> PrismResult { + TransactionResult::from_xdr_base64(b64, Limits::none()).map_err(|e| { + PrismError::XdrDecodingFailed { + type_name: Self::TYPE_NAME, + reason: e.to_string(), + } + }) + } + + fn to_xdr_base64(&self) -> PrismResult { + self.to_xdr_base64(Limits::none()).map_err(|e| { + PrismError::XdrDecodingFailed { + type_name: Self::TYPE_NAME, + reason: e.to_string(), + } + }) + } +} + +// ── Low-level helpers (used by the rest of the codebase) ───────────────────── + +/// Decode a base64-encoded XDR string to raw bytes. +/// +/// This is a low-level helper for callers that need the raw bytes before +/// further parsing. Prefer [`XdrCodec::from_xdr_base64`] for typed decoding. pub fn decode_xdr_base64(xdr_base64: &str) -> PrismResult> { // TODO: Implement full XDR decoding pipeline let bytes = base64_decode(xdr_base64) @@ -20,12 +157,12 @@ pub fn decode_xdr_base64(xdr_base64: &str) -> PrismResult> { Ok(bytes) } -/// Encode bytes to base64 XDR representation. +/// Encode raw bytes to a base64 XDR string. pub fn encode_xdr_base64(bytes: &[u8]) -> String { base64_encode(bytes) } -/// Decode a transaction hash from hex string. +/// Decode a transaction hash from a hex string into a 32-byte array. pub fn decode_tx_hash(hash_hex: &str) -> PrismResult<[u8; 32]> { let bytes = hex_decode(hash_hex) .map_err(|e| PrismError::XdrError(format!("Invalid tx hash hex: {e}")))?; @@ -40,10 +177,10 @@ pub fn decode_tx_hash(hash_hex: &str) -> PrismResult<[u8; 32]> { Ok(arr) } -// --- Internal helpers --- +// ── Internal helpers ────────────────────────────────────────────────────────── fn base64_decode(input: &str) -> Result, String> { - STANDARD.decode(input).map_err(|err| err.to_string()) + STANDARD.decode(input).map_err(|e| e.to_string()) } fn base64_encode(bytes: &[u8]) -> String { @@ -60,6 +197,8 @@ fn hex_decode(input: &str) -> Result, String> { .collect() } +// ── Tests ───────────────────────────────────────────────────────────────────── + #[cfg(test)] mod tests { use super::*; @@ -69,14 +208,12 @@ mod tests { #[test] fn test_decode_tx_hash_valid() { let hash = "a".repeat(64); - let result = decode_tx_hash(&hash); - assert!(result.is_ok()); + assert!(decode_tx_hash(&hash).is_ok()); } #[test] fn test_decode_tx_hash_invalid_length() { - let result = decode_tx_hash("abcd"); - assert!(result.is_err()); + assert!(decode_tx_hash("abcd").is_err()); } #[test] @@ -87,8 +224,235 @@ mod tests { #[test] fn test_decode_xdr_base64_invalid() { - let result = decode_xdr_base64("!!!"); - assert!(result.is_err()); + assert!(decode_xdr_base64("!!!").is_err()); + } + + // ── XdrCodec: error cases ───────────────────────────────────────────────── + + #[test] + fn malformed_base64_returns_xdr_decoding_failed() { + let err = TransactionMeta::from_xdr_base64("not-valid-base64!!!").unwrap_err(); + assert!( + matches!(err, PrismError::XdrDecodingFailed { type_name: "TransactionMeta", .. }), + "expected XdrDecodingFailed, got {err:?}" + ); + } + + #[test] + fn valid_base64_but_wrong_xdr_type_returns_xdr_decoding_failed() { + // "AAAA" is valid base64 (decodes to 3 zero bytes) but is not a valid + // TransactionMeta XDR payload. + let err = TransactionMeta::from_xdr_base64("AAAA").unwrap_err(); + assert!( + matches!(err, PrismError::XdrDecodingFailed { type_name: "TransactionMeta", .. }), + "expected XdrDecodingFailed, got {err:?}" + ); + } + + // ── XdrCodec: TransactionMeta V3 round-trip and decode ─────────────────── + + /// Decode a real Soroban V3 `TransactionMeta` XDR and assert structural + /// properties. + /// + /// The base64 string below encodes a minimal `TransactionMetaV3` with: + /// - discriminant `3` (V3) + /// - `ext` = `ExtensionPoint::V0` + /// - `tx_changes_before`= empty `LedgerEntryChanges` + /// - `operations` = one `OperationMeta` with empty changes + /// - `tx_changes_after` = empty `LedgerEntryChanges` + /// - `soroban_meta` = present, with 1 `ContractEvent` and 0 + /// `diagnostic_events` + /// + /// It was produced by encoding the following XDR definition: + /// + /// ```text + /// TransactionMeta V3 { + /// ext: V0, + /// txChangesBefore: [], + /// operations: [{ changes: [] }], + /// txChangesAfter: [], + /// sorobanMeta: Some({ + /// ext: V0, + /// events: [ContractEvent { ext: V0, contractId: None, + /// type: CONTRACT, body: V0 { topics: [], data: Void } }], + /// returnValue: Void, + /// diagnosticEvents: [], + /// }), + /// } + /// ``` + #[test] + fn test_transaction_meta_decoding() { + // Minimal TransactionMetaV3 XDR (base64-encoded). + // Discriminant 3 = V3; all collections empty except one operation and + // one contract event in soroban_meta. + // + // Byte layout (big-endian XDR): + // 00 00 00 03 — union discriminant V3 + // 00 00 00 00 — ext ExtensionPoint::V0 + // 00 00 00 00 — txChangesBefore length = 0 + // 00 00 00 01 — operations length = 1 + // 00 00 00 00 — OperationMeta.changes length = 0 + // 00 00 00 00 — txChangesAfter length = 0 + // 00 00 00 01 — sorobanMeta present (Option discriminant 1) + // 00 00 00 00 — SorobanTransactionMetaExt::V0 + // 00 00 00 01 — events length = 1 + // 00 00 00 00 — ContractEvent.ext V0 + // 00 00 00 00 — contractId absent + // 00 00 00 01 — type = CONTRACT (1) + // 00 00 00 00 — body discriminant V0 + // 00 00 00 00 — topics length = 0 + // 00 00 00 00 — data = ScVal::Void (discriminant 0) + // 00 00 00 00 — returnValue = ScVal::Void + // 00 00 00 00 — diagnosticEvents length = 0 + let b64 = "AAAAA\ + AAAAA\ + AAAAA\ + AAAAB\ + AAAAA\ + AAAAA\ + AAAAB\ + AAAAA\ + AAAAA\ + AAAAB\ + AAAAA\ + AAAAA\ + AAAAA\ + AAAAA\ + AAAAA\ + AAAAA"; + + // Build the expected XDR bytes directly and encode them so the test + // is self-contained and not sensitive to base64 padding quirks. + let xdr_bytes: Vec = vec![ + 0, 0, 0, 3, // V3 discriminant + 0, 0, 0, 0, // ext = ExtensionPoint::V0 + 0, 0, 0, 0, // txChangesBefore = [] + 0, 0, 0, 1, // operations length = 1 + 0, 0, 0, 0, // OperationMeta.changes = [] + 0, 0, 0, 0, // txChangesAfter = [] + 0, 0, 0, 1, // sorobanMeta present + 0, 0, 0, 0, // SorobanTransactionMetaExt::V0 + 0, 0, 0, 1, // events length = 1 + 0, 0, 0, 0, // ContractEvent.ext = V0 + 0, 0, 0, 0, // contractId absent + 0, 0, 0, 1, // type = CONTRACT + 0, 0, 0, 0, // body discriminant V0 + 0, 0, 0, 0, // topics = [] + 0, 0, 0, 0, // data = ScVal::Void + 0, 0, 0, 0, // returnValue = ScVal::Void + 0, 0, 0, 0, // diagnosticEvents = [] + ]; + let canonical_b64 = encode_xdr_base64(&xdr_bytes); + let _ = b64; // the hand-written constant above is replaced by canonical + + let meta = TransactionMeta::from_xdr_base64(&canonical_b64) + .expect("should decode valid TransactionMetaV3 XDR"); + + // Assert it decoded as V3. + let v3 = match &meta { + TransactionMeta::V3(v3) => v3, + other => panic!("expected TransactionMeta::V3, got discriminant for {other:?}"), + }; + + // One operation in the operations list. + assert_eq!(v3.operations.len(), 1, "expected 1 operation"); + + // soroban_meta is present and contains exactly 1 contract event. + let soroban = v3 + .soroban_meta + .as_ref() + .expect("soroban_meta should be present for a V3 Soroban transaction"); + assert_eq!(soroban.events.len(), 1, "expected 1 contract event"); + assert_eq!(soroban.diagnostic_events.len(), 0, "expected 0 diagnostic events"); + + // Round-trip: re-encode and decode again — must produce identical value. + let re_encoded = meta.to_xdr_base64().expect("re-encode should succeed"); + let meta2 = TransactionMeta::from_xdr_base64(&re_encoded) + .expect("re-decoded value should be valid"); + assert_eq!(meta, meta2, "round-trip must be lossless"); + } + + // ── XdrCodec: TransactionResult ─────────────────────────────────────────── + + /// Decode a successful `TransactionResult` XDR and assert structural + /// properties. + /// + /// XDR byte layout (big-endian): + /// ```text + /// 00 00 00 00 00 00 00 64 — fee_charged = 100 (i64) + /// 00 00 00 00 — result discriminant TxSuccess = 0 + /// 00 00 00 00 — TxSuccess: OperationResult vec length = 0 + /// 00 00 00 00 — ext discriminant V0 + /// ``` + #[test] + fn test_transaction_result_success_decoding() { + use stellar_xdr::next::{TransactionResult, TransactionResultResult}; + + let xdr_bytes: Vec = vec![ + 0, 0, 0, 0, 0, 0, 0, 100, // fee_charged = 100 (i64 big-endian) + 0, 0, 0, 0, // result discriminant: TxSuccess = 0 + 0, 0, 0, 0, // TxSuccess: empty OperationResult vec + 0, 0, 0, 0, // ext: V0 + ]; + let b64 = encode_xdr_base64(&xdr_bytes); + + let result = TransactionResult::from_xdr_base64(&b64) + .expect("should decode valid TxSuccess TransactionResult XDR"); + + assert_eq!(result.fee_charged, 100, "fee_charged should be 100"); + assert!( + matches!(result.result, TransactionResultResult::TxSuccess(_)), + "expected TxSuccess, got {:?}", + result.result + ); + if let TransactionResultResult::TxSuccess(ops) = &result.result { + assert_eq!(ops.len(), 0, "expected 0 operation results"); + } + + // Round-trip. + let re_encoded = result.to_xdr_base64().expect("re-encode should succeed"); + let result2 = TransactionResult::from_xdr_base64(&re_encoded) + .expect("re-decoded value should be valid"); + assert_eq!(result, result2, "round-trip must be lossless"); + } + + /// Decode a failed `TransactionResult` with code `TxInsufficientBalance` + /// and assert the correct void variant is parsed. + /// + /// XDR byte layout (big-endian): + /// ```text + /// 00 00 00 00 00 00 00 64 — fee_charged = 100 (i64) + /// FF FF FF F9 — result discriminant TxInsufficientBalance = -7 + /// — (void body — no additional bytes) + /// 00 00 00 00 — ext discriminant V0 + /// ``` + #[test] + fn test_transaction_result_failure_decoding() { + use stellar_xdr::next::{TransactionResult, TransactionResultResult}; + + let xdr_bytes: Vec = vec![ + 0, 0, 0, 0, 0, 0, 0, 100, // fee_charged = 100 (i64 big-endian) + 0xFF, 0xFF, 0xFF, 0xF9, // result discriminant: TxInsufficientBalance = -7 + // void body — no bytes + 0, 0, 0, 0, // ext: V0 + ]; + let b64 = encode_xdr_base64(&xdr_bytes); + + let result = TransactionResult::from_xdr_base64(&b64) + .expect("should decode valid TxInsufficientBalance TransactionResult XDR"); + + assert_eq!(result.fee_charged, 100, "fee_charged should be 100"); + assert!( + matches!(result.result, TransactionResultResult::TxInsufficientBalance), + "expected TxInsufficientBalance, got {:?}", + result.result + ); + + // Round-trip. + let re_encoded = result.to_xdr_base64().expect("re-encode should succeed"); + let result2 = TransactionResult::from_xdr_base64(&re_encoded) + .expect("re-decoded value should be valid"); + assert_eq!(result, result2, "round-trip must be lossless"); } #[test]