From 6a9431a6f75a716d4979a69ca77e0571d5d5b93f Mon Sep 17 00:00:00 2001 From: JamesVictor-O Date: Thu, 5 Mar 2026 09:10:55 +0100 Subject: [PATCH 1/3] feat: add unified error handling and custom error types Closes #10 - Create `src/errors.rs` with `StellarAidError` enum covering all required variants: HorizonError, SorobanError, KeypairError, ValidationError, TransactionFailed, ContractError, NetworkError - Derive `Display` and `Error` via `thiserror` for ergonomic error handling - Implement `From` conversions for existing error types (KeyError, StellarError, TokenSetupError, reqwest::Error) enabling `?` propagation - Replace bare `unwrap()` calls in production contract code with `expect()` providing descriptive panic messages (campaign and withdrawal contracts) - Replace `unwrap_or_default()` in friendbot with proper `?` error propagation - Add comprehensive unit tests for all error conversions and formatting Made-with: Cursor --- contracts/campaign/src/lib.rs | 12 ++- contracts/withdrawal/src/lib.rs | 7 +- src/errors.rs | 150 +++++++++++++++++++++++++++++++ src/friendbot/utils/friendbot.rs | 4 +- src/main.rs | 1 + 5 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 src/errors.rs diff --git a/contracts/campaign/src/lib.rs b/contracts/campaign/src/lib.rs index 4df4daf..68b4457 100644 --- a/contracts/campaign/src/lib.rs +++ b/contracts/campaign/src/lib.rs @@ -18,14 +18,20 @@ impl CampaignContract { /// Get campaign status pub fn get_status(env: Env) -> (Symbol, Symbol, i128, u64) { let key = Symbol::new(&env, "campaign_data"); - env.storage().instance().get(&key).unwrap() + env.storage() + .instance() + .get(&key) + .expect("campaign not initialized") } /// Check if campaign is active pub fn is_active(env: Env) -> bool { let key = Symbol::new(&env, "campaign_data"); - let (_id, _title, _target, deadline): (Symbol, Symbol, i128, u64) = - env.storage().instance().get(&key).unwrap(); + let (_id, _title, _target, deadline): (Symbol, Symbol, i128, u64) = env + .storage() + .instance() + .get(&key) + .expect("campaign not initialized"); let current_time = env.ledger().timestamp(); current_time < deadline diff --git a/contracts/withdrawal/src/lib.rs b/contracts/withdrawal/src/lib.rs index 8efb754..59cdc55 100644 --- a/contracts/withdrawal/src/lib.rs +++ b/contracts/withdrawal/src/lib.rs @@ -18,8 +18,11 @@ impl WithdrawalContract { /// Withdraw funds from the contract pub fn withdraw(env: Env, amount: i128) -> bool { let key = Symbol::new(&env, "settings"); - let (beneficiary, max_withdrawal): (Address, i128) = - env.storage().instance().get(&key).unwrap(); + let (beneficiary, max_withdrawal): (Address, i128) = env + .storage() + .instance() + .get(&key) + .expect("withdrawal not initialized"); beneficiary.require_auth(); diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..c9138a9 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,150 @@ +//! Unified error types for the StellarAid blockchain integration layer. +//! +//! [`StellarAidError`] is the top-level error enum that downstream code should +//! use as its return type. Each variant maps to a distinct failure domain and +//! carries enough context for callers to decide how to recover (or surface a +//! useful message). +//! +//! Existing per-module error types ([`KeyError`](crate::utils::keypair::KeyError), +//! [`StellarError`](crate::friendbot::utils::types::StellarError), +//! [`TokenSetupError`](crate::setup::token_setup::TokenSetupError)) are +//! automatically converted via [`From`] impls so the `?` operator works +//! transparently. + +use thiserror::Error; + +use crate::friendbot::utils::types::StellarError; +use crate::setup::token_setup::TokenSetupError; +use crate::utils::keypair::KeyError; + +/// Top-level error type for the StellarAid integration layer. +#[derive(Debug, Error)] +pub enum StellarAidError { + /// Stellar Horizon REST-API returned an error or an unexpected response. + #[error("Horizon API error: {0}")] + HorizonError(String), + + /// Soroban JSON-RPC call failed. + #[error("Soroban RPC error (code {code}): {message}")] + SorobanError { code: i64, message: String }, + + /// Key generation, parsing, or derivation failed. + #[error("Keypair error: {0}")] + KeypairError(String), + + /// Input or state did not meet a business-logic precondition. + #[error("Validation error: {0}")] + ValidationError(String), + + /// A submitted transaction was rejected or reverted by the network. + #[error("Transaction failed: {0}")] + TransactionFailed(String), + + /// An on-chain smart-contract call returned an error. + #[error("Contract error: {0}")] + ContractError(String), + + /// A lower-level network / HTTP / I/O error. + #[error("Network error: {0}")] + NetworkError(String), +} + +// ── From impls for ergonomic `?` propagation ──────────────────────────────── + +impl From for StellarAidError { + fn from(err: KeyError) -> Self { + Self::KeypairError(err.to_string()) + } +} + +impl From for StellarAidError { + fn from(err: StellarError) -> Self { + match &err { + StellarError::FriendbotNotAvailable { .. } => Self::NetworkError(err.to_string()), + StellarError::HttpRequestFailed(_) => Self::NetworkError(err.to_string()), + StellarError::FriendbotError { .. } => Self::HorizonError(err.to_string()), + } + } +} + +impl From for StellarAidError { + fn from(err: TokenSetupError) -> Self { + Self::ContractError(err.to_string()) + } +} + +impl From for StellarAidError { + fn from(err: reqwest::Error) -> Self { + Self::NetworkError(err.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::friendbot::utils::types::StellarError; + use crate::setup::token_setup::TokenSetupError; + use crate::utils::keypair::KeyError; + + #[test] + fn key_error_converts_to_keypair_variant() { + let err: StellarAidError = KeyError::InvalidSecretKey("bad".into()).into(); + assert!(matches!(err, StellarAidError::KeypairError(_))); + assert!(err.to_string().contains("bad")); + } + + #[test] + fn stellar_friendbot_not_available_converts_to_network() { + let err: StellarAidError = StellarError::FriendbotNotAvailable { + network: "mainnet".into(), + } + .into(); + assert!(matches!(err, StellarAidError::NetworkError(_))); + } + + #[test] + fn stellar_http_failure_converts_to_network() { + let err: StellarAidError = + StellarError::HttpRequestFailed("timeout".into()).into(); + assert!(matches!(err, StellarAidError::NetworkError(_))); + } + + #[test] + fn stellar_friendbot_error_converts_to_horizon() { + let err: StellarAidError = StellarError::FriendbotError { + status: 500, + body: "internal".into(), + } + .into(); + assert!(matches!(err, StellarAidError::HorizonError(_))); + } + + #[test] + fn token_setup_error_converts_to_contract() { + let err: StellarAidError = TokenSetupError::CommandFailed { + command: "stellar deploy".into(), + stderr: "oops".into(), + } + .into(); + assert!(matches!(err, StellarAidError::ContractError(_))); + } + + #[test] + fn display_includes_variant_prefix() { + let err = StellarAidError::ValidationError("amount must be positive".into()); + assert_eq!( + err.to_string(), + "Validation error: amount must be positive" + ); + } + + #[test] + fn soroban_error_formats_code_and_message() { + let err = StellarAidError::SorobanError { + code: -32600, + message: "invalid request".into(), + }; + assert!(err.to_string().contains("-32600")); + assert!(err.to_string().contains("invalid request")); + } +} diff --git a/src/friendbot/utils/friendbot.rs b/src/friendbot/utils/friendbot.rs index 1271e89..0b2e2f3 100644 --- a/src/friendbot/utils/friendbot.rs +++ b/src/friendbot/utils/friendbot.rs @@ -71,7 +71,9 @@ pub fn fund_account(public_key: &str) -> Result<(), StellarError> { return Err(StellarError::FriendbotError { status: 400, body }); } - let body = response.text().unwrap_or_default(); + let body = response + .text() + .map_err(|e: reqwest::Error| StellarError::HttpRequestFailed(e.to_string()))?; Err(StellarError::FriendbotError { status: status.as_u16(), body, diff --git a/src/main.rs b/src/main.rs index f6c4fc2..33d1e54 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +pub mod errors; pub mod friendbot; mod setup; pub mod utils; From 94b02cf198f41d77ec90d3d87f7dbc8998773ae7 Mon Sep 17 00:00:00 2001 From: JamesVictor-O Date: Thu, 5 Mar 2026 09:27:45 +0100 Subject: [PATCH 2/3] cargo fmt --- src/errors.rs | 296 +++++++++++++++++++++++++------------------------- 1 file changed, 146 insertions(+), 150 deletions(-) diff --git a/src/errors.rs b/src/errors.rs index c9138a9..81f4e55 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,150 +1,146 @@ -//! Unified error types for the StellarAid blockchain integration layer. -//! -//! [`StellarAidError`] is the top-level error enum that downstream code should -//! use as its return type. Each variant maps to a distinct failure domain and -//! carries enough context for callers to decide how to recover (or surface a -//! useful message). -//! -//! Existing per-module error types ([`KeyError`](crate::utils::keypair::KeyError), -//! [`StellarError`](crate::friendbot::utils::types::StellarError), -//! [`TokenSetupError`](crate::setup::token_setup::TokenSetupError)) are -//! automatically converted via [`From`] impls so the `?` operator works -//! transparently. - -use thiserror::Error; - -use crate::friendbot::utils::types::StellarError; -use crate::setup::token_setup::TokenSetupError; -use crate::utils::keypair::KeyError; - -/// Top-level error type for the StellarAid integration layer. -#[derive(Debug, Error)] -pub enum StellarAidError { - /// Stellar Horizon REST-API returned an error or an unexpected response. - #[error("Horizon API error: {0}")] - HorizonError(String), - - /// Soroban JSON-RPC call failed. - #[error("Soroban RPC error (code {code}): {message}")] - SorobanError { code: i64, message: String }, - - /// Key generation, parsing, or derivation failed. - #[error("Keypair error: {0}")] - KeypairError(String), - - /// Input or state did not meet a business-logic precondition. - #[error("Validation error: {0}")] - ValidationError(String), - - /// A submitted transaction was rejected or reverted by the network. - #[error("Transaction failed: {0}")] - TransactionFailed(String), - - /// An on-chain smart-contract call returned an error. - #[error("Contract error: {0}")] - ContractError(String), - - /// A lower-level network / HTTP / I/O error. - #[error("Network error: {0}")] - NetworkError(String), -} - -// ── From impls for ergonomic `?` propagation ──────────────────────────────── - -impl From for StellarAidError { - fn from(err: KeyError) -> Self { - Self::KeypairError(err.to_string()) - } -} - -impl From for StellarAidError { - fn from(err: StellarError) -> Self { - match &err { - StellarError::FriendbotNotAvailable { .. } => Self::NetworkError(err.to_string()), - StellarError::HttpRequestFailed(_) => Self::NetworkError(err.to_string()), - StellarError::FriendbotError { .. } => Self::HorizonError(err.to_string()), - } - } -} - -impl From for StellarAidError { - fn from(err: TokenSetupError) -> Self { - Self::ContractError(err.to_string()) - } -} - -impl From for StellarAidError { - fn from(err: reqwest::Error) -> Self { - Self::NetworkError(err.to_string()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::friendbot::utils::types::StellarError; - use crate::setup::token_setup::TokenSetupError; - use crate::utils::keypair::KeyError; - - #[test] - fn key_error_converts_to_keypair_variant() { - let err: StellarAidError = KeyError::InvalidSecretKey("bad".into()).into(); - assert!(matches!(err, StellarAidError::KeypairError(_))); - assert!(err.to_string().contains("bad")); - } - - #[test] - fn stellar_friendbot_not_available_converts_to_network() { - let err: StellarAidError = StellarError::FriendbotNotAvailable { - network: "mainnet".into(), - } - .into(); - assert!(matches!(err, StellarAidError::NetworkError(_))); - } - - #[test] - fn stellar_http_failure_converts_to_network() { - let err: StellarAidError = - StellarError::HttpRequestFailed("timeout".into()).into(); - assert!(matches!(err, StellarAidError::NetworkError(_))); - } - - #[test] - fn stellar_friendbot_error_converts_to_horizon() { - let err: StellarAidError = StellarError::FriendbotError { - status: 500, - body: "internal".into(), - } - .into(); - assert!(matches!(err, StellarAidError::HorizonError(_))); - } - - #[test] - fn token_setup_error_converts_to_contract() { - let err: StellarAidError = TokenSetupError::CommandFailed { - command: "stellar deploy".into(), - stderr: "oops".into(), - } - .into(); - assert!(matches!(err, StellarAidError::ContractError(_))); - } - - #[test] - fn display_includes_variant_prefix() { - let err = StellarAidError::ValidationError("amount must be positive".into()); - assert_eq!( - err.to_string(), - "Validation error: amount must be positive" - ); - } - - #[test] - fn soroban_error_formats_code_and_message() { - let err = StellarAidError::SorobanError { - code: -32600, - message: "invalid request".into(), - }; - assert!(err.to_string().contains("-32600")); - assert!(err.to_string().contains("invalid request")); - } -} +//! Unified error types for the StellarAid blockchain integration layer. +//! +//! [`StellarAidError`] is the top-level error enum that downstream code should +//! use as its return type. Each variant maps to a distinct failure domain and +//! carries enough context for callers to decide how to recover (or surface a +//! useful message). +//! +//! Existing per-module error types ([`KeyError`](crate::utils::keypair::KeyError), +//! [`StellarError`](crate::friendbot::utils::types::StellarError), +//! [`TokenSetupError`](crate::setup::token_setup::TokenSetupError)) are +//! automatically converted via [`From`] impls so the `?` operator works +//! transparently. + +use thiserror::Error; + +use crate::friendbot::utils::types::StellarError; +use crate::setup::token_setup::TokenSetupError; +use crate::utils::keypair::KeyError; + +/// Top-level error type for the StellarAid integration layer. +#[derive(Debug, Error)] +pub enum StellarAidError { + /// Stellar Horizon REST-API returned an error or an unexpected response. + #[error("Horizon API error: {0}")] + HorizonError(String), + + /// Soroban JSON-RPC call failed. + #[error("Soroban RPC error (code {code}): {message}")] + SorobanError { code: i64, message: String }, + + /// Key generation, parsing, or derivation failed. + #[error("Keypair error: {0}")] + KeypairError(String), + + /// Input or state did not meet a business-logic precondition. + #[error("Validation error: {0}")] + ValidationError(String), + + /// A submitted transaction was rejected or reverted by the network. + #[error("Transaction failed: {0}")] + TransactionFailed(String), + + /// An on-chain smart-contract call returned an error. + #[error("Contract error: {0}")] + ContractError(String), + + /// A lower-level network / HTTP / I/O error. + #[error("Network error: {0}")] + NetworkError(String), +} + +// ── From impls for ergonomic `?` propagation ──────────────────────────────── + +impl From for StellarAidError { + fn from(err: KeyError) -> Self { + Self::KeypairError(err.to_string()) + } +} + +impl From for StellarAidError { + fn from(err: StellarError) -> Self { + match &err { + StellarError::FriendbotNotAvailable { .. } => Self::NetworkError(err.to_string()), + StellarError::HttpRequestFailed(_) => Self::NetworkError(err.to_string()), + StellarError::FriendbotError { .. } => Self::HorizonError(err.to_string()), + } + } +} + +impl From for StellarAidError { + fn from(err: TokenSetupError) -> Self { + Self::ContractError(err.to_string()) + } +} + +impl From for StellarAidError { + fn from(err: reqwest::Error) -> Self { + Self::NetworkError(err.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::friendbot::utils::types::StellarError; + use crate::setup::token_setup::TokenSetupError; + use crate::utils::keypair::KeyError; + + #[test] + fn key_error_converts_to_keypair_variant() { + let err: StellarAidError = KeyError::InvalidSecretKey("bad".into()).into(); + assert!(matches!(err, StellarAidError::KeypairError(_))); + assert!(err.to_string().contains("bad")); + } + + #[test] + fn stellar_friendbot_not_available_converts_to_network() { + let err: StellarAidError = StellarError::FriendbotNotAvailable { + network: "mainnet".into(), + } + .into(); + assert!(matches!(err, StellarAidError::NetworkError(_))); + } + + #[test] + fn stellar_http_failure_converts_to_network() { + let err: StellarAidError = StellarError::HttpRequestFailed("timeout".into()).into(); + assert!(matches!(err, StellarAidError::NetworkError(_))); + } + + #[test] + fn stellar_friendbot_error_converts_to_horizon() { + let err: StellarAidError = StellarError::FriendbotError { + status: 500, + body: "internal".into(), + } + .into(); + assert!(matches!(err, StellarAidError::HorizonError(_))); + } + + #[test] + fn token_setup_error_converts_to_contract() { + let err: StellarAidError = TokenSetupError::CommandFailed { + command: "stellar deploy".into(), + stderr: "oops".into(), + } + .into(); + assert!(matches!(err, StellarAidError::ContractError(_))); + } + + #[test] + fn display_includes_variant_prefix() { + let err = StellarAidError::ValidationError("amount must be positive".into()); + assert_eq!(err.to_string(), "Validation error: amount must be positive"); + } + + #[test] + fn soroban_error_formats_code_and_message() { + let err = StellarAidError::SorobanError { + code: -32600, + message: "invalid request".into(), + }; + assert!(err.to_string().contains("-32600")); + assert!(err.to_string().contains("invalid request")); + } +} From ae184f1623c44a461db41909d81d965eb27c4ccc Mon Sep 17 00:00:00 2001 From: JamesVictor-O Date: Thu, 5 Mar 2026 14:47:19 +0100 Subject: [PATCH 3/3] cargo fmt --- src/main.rs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/main.rs b/src/main.rs index f3086e2..3dfcc60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,8 @@ - -pub mod errors; - -pub mod friendbot; -pub mod horizon; -mod setup; -pub mod utils; - -fn main() {} - +pub mod errors; + +pub mod friendbot; +pub mod horizon; +mod setup; +pub mod utils; + +fn main() {}