Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions crates/core/src/types/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ pub struct NetworkConfig {
pub network_passphrase: String,
/// History archive URL(s).
pub archive_urls: Vec<String>,
/// 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 {
Expand All @@ -50,6 +65,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,
}
}

Expand All @@ -62,6 +78,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,
}
}

Expand All @@ -72,6 +89,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,
}
}

Expand All @@ -82,6 +100,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,
}
}
}
Expand Down
13 changes: 13 additions & 0 deletions crates/core/src/types/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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}"),
Expand Down
250 changes: 236 additions & 14 deletions crates/core/src/xdr/codec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,38 @@
//!
//! 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, 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<Self>;

use crate::types::error::{PrismError, PrismResult};
use base64::{engine::general_purpose::STANDARD, Engine as _};
Expand Down Expand Up @@ -40,20 +72,66 @@ impl XdrCodec for TransactionMeta {
}
}

/// Decode a base64-encoded XDR transaction result.
/// Decode / encode [`TransactionMeta`] XDR.
///
/// `TransactionMeta` is a union with four variants:
///
/// | Variant | Contents |
/// |---------|----------|
/// | `V0` | `VecM<OperationMeta>` |
/// | `V1` | `TransactionMetaV1` |
/// | `V2` | `TransactionMetaV2` |
/// | `V3` | `TransactionMetaV3` — Soroban; contains `soroban_meta` with `events` and `diagnostic_events` |
///
/// # Arguments
/// * `xdr_base64` - Base64-encoded XDR string
/// The indexer should match on the returned enum to access version-specific
/// fields:
///
/// # Returns
/// The raw decoded bytes, ready for further parsing.
/// ```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<Self> {
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<String> {
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<Vec<u8>> {
let bytes = base64_decode(xdr_base64)
.map_err(|e| PrismError::XdrError(format!("Base64 decode failed: {e}")))?;
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)
}
Expand Down Expand Up @@ -98,10 +176,10 @@ pub fn decode_tx_hash(hash_hex: &str) -> PrismResult<[u8; 32]> {
Ok(arr)
}

// --- Internal helpers ---
// ── Internal helpers ──────────────────────────────────────────────────────────

fn base64_decode(input: &str) -> Result<Vec<u8>, 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 {
Expand All @@ -122,6 +200,8 @@ fn hex_decode(input: &str) -> Result<Vec<u8>, String> {
.collect()
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -131,14 +211,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]
Expand All @@ -155,8 +233,152 @@ 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<u8> = 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");
}

#[test]
Expand Down