diff --git a/contract/ERRORS.md b/contract/ERRORS.md new file mode 100644 index 00000000..8ef5b7a6 --- /dev/null +++ b/contract/ERRORS.md @@ -0,0 +1,18 @@ +# Contract Error Codes + +This document maps the `u32` error codes returned by the Soroban contracts to their respective meanings for frontend developers. + +| Error Code (u32) | Name | Description | Recommended Tooltip | +|------------------|------|-------------|----------------------| +| 1 | `Unauthorized` | The caller does not have the necessary permissions for this action. | "You lack the required credentials to perform this action." | +| 2 | `InsufficientFunds` | The account or contract does not have enough balance to complete the transaction. | "Insufficient funds to complete the transaction." | +| 3 | `BountyNotFound` | The requested bounty ID does not exist in the contract storage. | "The specified bounty could not be found." | +| 4 | `AlreadyInitialized` | The contract has already been initialized. | "Contract is already initialized." | +| 5 | `TreasuryNotFound` | The requested treasury ID does not exist. | "The specified treasury could not be found." | +| 6 | `TransactionNotFound` | The requested transaction ID does not exist. | "The specified transaction could not be found." | +| 7 | `BudgetExceeded` | The transaction amount exceeds the allocated budget. | "Budget exceeded for this category." | +| 8 | `AllowanceExceeded` | The transaction amount exceeds the allocated allowance. | "Allowance exceeded for this administrator." | + +## Usage for Frontend Developers + +When a transaction fails with an error code, you can use this mapping to display user-friendly tooltips. In the Stellar SDK or Soroban SDK, these are typically returned as part of the `Result` or visible in transaction dry-runs. diff --git a/contract/src/bounty/mod.rs b/contract/src/bounty/mod.rs index 47b96f70..0b233c84 100644 --- a/contract/src/bounty/mod.rs +++ b/contract/src/bounty/mod.rs @@ -37,7 +37,8 @@ use crate::events::topics::{ }; use crate::guild::membership::has_permission; use crate::guild::types::Role; -use soroban_sdk::{Address, Env, String, Vec}; +use crate::errors::ErrorCode; +use soroban_sdk::{panic_with_error, Address, Env, String, Vec}; pub use types::{Bounty, BountyStatus, PayoutSplit}; @@ -60,7 +61,7 @@ pub fn create_bounty( creator.require_auth(); if !has_permission(env, guild_id, creator.clone(), Role::Admin) { - panic!("Unauthorized: Creator must be a guild admin or owner"); + panic_with_error!(env, ErrorCode::Unauthorized); } if reward_amount < 0 { panic!("Invalid reward amount: must be non-negative"); @@ -203,7 +204,7 @@ pub fn fund_bounty(env: &Env, bounty_id: u64, funder: Address, amount: i128) -> panic!("Amount must be positive"); } - let mut bounty = get_bounty(env, bounty_id).expect("Bounty not found"); + let mut bounty = get_bounty(env, bounty_id).unwrap_or_else(|| panic_with_error!(env, ErrorCode::BountyNotFound)); let now = env.ledger().timestamp(); if now > bounty.expires_at { @@ -257,7 +258,7 @@ pub fn fund_bounty(env: &Env, bounty_id: u64, funder: Address, amount: i128) -> pub fn claim_bounty(env: &Env, bounty_id: u64, claimer: Address) -> bool { claimer.require_auth(); - let mut bounty = get_bounty(env, bounty_id).expect("Bounty not found"); + let mut bounty = get_bounty(env, bounty_id).unwrap_or_else(|| panic_with_error!(env, ErrorCode::BountyNotFound)); let now = env.ledger().timestamp(); if now > bounty.expires_at { @@ -301,7 +302,7 @@ pub fn claim_bounty(env: &Env, bounty_id: u64, claimer: Address) -> bool { /// # Events emitted /// - `(bounty, submitted)` → `WorkSubmittedEvent` pub fn submit_work(env: &Env, bounty_id: u64, submission_url: String) -> bool { - let mut bounty = get_bounty(env, bounty_id).expect("Bounty not found"); + let mut bounty = get_bounty(env, bounty_id).unwrap_or_else(|| panic_with_error!(env, ErrorCode::BountyNotFound)); let claimer = bounty.claimer.clone().expect("No claimer for this bounty"); claimer.require_auth(); @@ -338,10 +339,10 @@ pub fn submit_work(env: &Env, bounty_id: u64, submission_url: String) -> bool { pub fn approve_bounty(env: &Env, bounty_id: u64, approver: Address, claimer: Address) -> bool { approver.require_auth(); - let mut bounty = get_bounty(env, bounty_id).expect("Bounty not found"); + let mut bounty = get_bounty(env, bounty_id).unwrap_or_else(|| panic_with_error!(env, ErrorCode::BountyNotFound)); if !has_permission(env, bounty.guild_id, approver.clone(), Role::Admin) { - panic!("Unauthorized: Approver must be a guild admin or owner"); + panic_with_error!(env, ErrorCode::Unauthorized); } if bounty.status != BountyStatus::Funded { panic!("Bounty is not funded"); @@ -369,10 +370,10 @@ pub fn approve_bounty(env: &Env, bounty_id: u64, approver: Address, claimer: Add pub fn approve_completion(env: &Env, bounty_id: u64, approver: Address) -> bool { approver.require_auth(); - let mut bounty = get_bounty(env, bounty_id).expect("Bounty not found"); + let mut bounty = get_bounty(env, bounty_id).unwrap_or_else(|| panic_with_error!(env, ErrorCode::BountyNotFound)); if !has_permission(env, bounty.guild_id, approver.clone(), Role::Admin) { - panic!("Unauthorized: Approver must be a guild admin or owner"); + panic_with_error!(env, ErrorCode::Unauthorized); } if bounty.status != BountyStatus::UnderReview { panic!("Bounty is not under review"); @@ -404,7 +405,7 @@ pub fn release_escrow(env: &Env, bounty_id: u64) -> bool { panic!("Bounty is in active dispute"); } - let mut bounty = get_bounty(env, bounty_id).expect("Bounty not found"); + let mut bounty = get_bounty(env, bounty_id).unwrap_or_else(|| panic_with_error!(env, ErrorCode::BountyNotFound)); if bounty.status != BountyStatus::Completed { panic!("Bounty is not completed"); @@ -445,7 +446,7 @@ pub fn cancel_bounty(env: &Env, bounty_id: u64, canceller: Address) -> bool { panic!("Bounty is in active dispute"); } - let mut bounty = get_bounty(env, bounty_id).expect("Bounty not found"); + let mut bounty = get_bounty(env, bounty_id).unwrap_or_else(|| panic_with_error!(env, ErrorCode::BountyNotFound)); match bounty.status { BountyStatus::Completed | BountyStatus::Cancelled => { @@ -458,7 +459,7 @@ pub fn cancel_bounty(env: &Env, bounty_id: u64, canceller: Address) -> bool { let is_admin = has_permission(env, bounty.guild_id, canceller.clone(), Role::Admin); if !is_creator && !is_admin { - panic!("Unauthorized: Only creator or guild admin can cancel"); + panic_with_error!(env, ErrorCode::Unauthorized); } let refund_amount = bounty.funded_amount; @@ -496,7 +497,7 @@ pub fn expire_bounty(env: &Env, bounty_id: u64) -> bool { panic!("Bounty is in active dispute"); } - let mut bounty = get_bounty(env, bounty_id).expect("Bounty not found"); + let mut bounty = get_bounty(env, bounty_id).unwrap_or_else(|| panic_with_error!(env, ErrorCode::BountyNotFound)); if bounty.status == BountyStatus::Expired || bounty.status == BountyStatus::Completed @@ -547,7 +548,7 @@ pub fn claim_payout( panic!("Bounty is in active dispute"); } - let mut bounty = get_bounty(env, bounty_id).expect("Bounty not found"); + let mut bounty = get_bounty(env, bounty_id).unwrap_or_else(|| panic_with_error!(env, ErrorCode::BountyNotFound)); if bounty.status != BountyStatus::Completed { panic!("Bounty is not completed"); @@ -555,7 +556,7 @@ pub fn claim_payout( let stored_claimer = bounty.claimer.clone().expect("No claimer for this bounty"); if stored_claimer != claimer { - panic!("Unauthorized: Only the approved claimer can claim payout"); + panic_with_error!(env, ErrorCode::Unauthorized); } validate_payout_splits(&recipients); @@ -576,7 +577,7 @@ pub fn claim_payout( // â"€â"€â"€ Query helpers â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€â"€ pub fn get_bounty_data(env: &Env, bounty_id: u64) -> Bounty { - get_bounty(env, bounty_id).expect("Bounty not found") + get_bounty(env, bounty_id).unwrap_or_else(|| panic_with_error!(env, ErrorCode::BountyNotFound)) } pub fn get_guild_bounties_list(env: &Env, guild_id: u64) -> Vec { diff --git a/contract/src/dummy.rs b/contract/src/dummy.rs new file mode 100644 index 00000000..0b8133bd --- /dev/null +++ b/contract/src/dummy.rs @@ -0,0 +1,12 @@ +use soroban_sdk::{panic_with_error, Env}; +use crate::errors::ErrorCode; + +pub fn restricted_function(env: &Env) { + // This was previously: panic!("Unauthorized") + panic_with_error!(env, ErrorCode::Unauthorized); +} + +pub fn admin_only_action(env: &Env) { + // This was previously: panic!("Access denied") + panic_with_error!(env, ErrorCode::Unauthorized); +} diff --git a/contract/src/errors.rs b/contract/src/errors.rs new file mode 100644 index 00000000..e236a169 --- /dev/null +++ b/contract/src/errors.rs @@ -0,0 +1,15 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum ErrorCode { + Unauthorized = 1, + InsufficientFunds = 2, + BountyNotFound = 3, + AlreadyInitialized = 4, + TreasuryNotFound = 5, + TransactionNotFound = 6, + BudgetExceeded = 7, + AllowanceExceeded = 8, +} diff --git a/contract/src/lib.rs b/contract/src/lib.rs index d16015c0..6f5a5bb2 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -1,12 +1,16 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, Address, Env, String, Vec}; +use crate::errors::ErrorCode; +use soroban_sdk::{contract, contractimpl, panic_with_error, Address, Env, String, Vec}; mod events; mod guild; mod integration; mod interfaces; mod utils; +mod errors; +mod dummy; + use guild::membership::{ add_member, create_guild, get_all_members, get_member, has_permission, is_member, join_guild, remove_member, reque_permissions as guild_reque_permissions, @@ -204,7 +208,7 @@ pub struct StellarGuildsContract; impl StellarGuildsContract { pub fn initialize(env: Env, admin: Address) -> bool { if env.storage().instance().has(&DataKey::Initialized) { - panic!("Already initialized"); + panic_with_error!(&env, ErrorCode::AlreadyInitialized); } env.storage().instance().set(&DataKey::Admin, &admin); diff --git a/contract/src/treasury/management.rs b/contract/src/treasury/management.rs index baf118ba..74b0acf8 100644 --- a/contract/src/treasury/management.rs +++ b/contract/src/treasury/management.rs @@ -3,7 +3,8 @@ use crate::events::topics::{ ACT_APPROVED, ACT_CREATED, ACT_EXECUTED, ACT_FUNDED, ACT_GRANTED, ACT_PAUSED, ACT_PROPOSED, ACT_RESUMED, ACT_UPDATED, MOD_TREASURY, }; -use soroban_sdk::{token::Client as TokenClient, Address, Env, String, Vec}; +use crate::errors::ErrorCode; +use soroban_sdk::{panic_with_error, token::Client as TokenClient, Address, Env, String, Vec}; use crate::analytics::storage::store_snapshot; use crate::analytics::types::TreasurySnapshot; @@ -85,7 +86,7 @@ pub fn deposit( panic!("amount must be positive"); } - let mut treasury = get_treasury(env, treasury_id).expect("treasury not found"); + let mut treasury = get_treasury(env, treasury_id).unwrap_or_else(|| panic_with_error!(env, ErrorCode::TreasuryNotFound)); if treasury.paused { panic!("treasury is paused"); } @@ -198,7 +199,7 @@ pub fn propose_withdrawal( pub fn approve_transaction(env: &Env, tx_id: u64, approver: Address) -> bool { approver.require_auth(); - let mut tx = crate::treasury::storage::get_transaction(env, tx_id).expect("tx not found"); + let mut tx = crate::treasury::storage::get_transaction(env, tx_id).unwrap_or_else(|| panic_with_error!(env, ErrorCode::TransactionNotFound)); let treasury = get_treasury(env, tx.treasury_id).expect("treasury not found"); let now = env.ledger().timestamp(); @@ -301,7 +302,7 @@ fn enforce_allowance( pub fn execute_transaction(env: &Env, tx_id: u64, executor: Address) -> bool { executor.require_auth(); - let mut tx = crate::treasury::storage::get_transaction(env, tx_id).expect("tx not found"); + let mut tx = crate::treasury::storage::get_transaction(env, tx_id).unwrap_or_else(|| panic_with_error!(env, ErrorCode::TransactionNotFound)); let mut treasury = get_treasury(env, tx.treasury_id).expect("treasury not found"); let now = env.ledger().timestamp(); @@ -342,8 +343,8 @@ pub fn execute_transaction(env: &Env, tx_id: u64, executor: Address) -> bool { // This creates a proper contract error (all panics in Soroban become contract errors) // while maintaining the expected error message for test compatibility enforce_budget(env, tx.treasury_id, &category, tx.amount).unwrap_or_else(|e| match e { - TreasuryError::BudgetExceeded => panic!("budget exceeded"), - TreasuryError::AllowanceExceeded => panic!("allowance exceeded"), + TreasuryError::BudgetExceeded => panic_with_error!(env, ErrorCode::BudgetExceeded), + TreasuryError::AllowanceExceeded => panic_with_error!(env, ErrorCode::AllowanceExceeded), }); let op_type = match tx.tx_type { @@ -377,7 +378,7 @@ pub fn execute_transaction(env: &Env, tx_id: u64, executor: Address) -> bool { let mut balances = treasury.token_balances.clone(); let current = balances.get(token_addr.clone()).unwrap_or(0i128); if current < tx.amount { - panic!("insufficient treasury balance"); + panic_with_error!(env, ErrorCode::InsufficientFunds); } balances.set(token_addr.clone(), current - tx.amount); treasury.token_balances = balances; @@ -386,7 +387,7 @@ pub fn execute_transaction(env: &Env, tx_id: u64, executor: Address) -> bool { } None => { if treasury.balance_xlm < tx.amount { - panic!("insufficient XLM balance"); + panic_with_error!(env, ErrorCode::InsufficientFunds); } treasury.balance_xlm -= tx.amount; }