diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 431d289..39f9460 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -4,7 +4,7 @@ extern crate alloc; use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, + contract, contractimpl, contracttype, contracterror, symbol_short, token, Address, Env, Symbol, Vec, }; @@ -65,12 +65,28 @@ pub struct SubEscrow { // Errors // --------------------------------------------------------------------------- -#[contracttype] +/// Standardised contract error enum. +/// +/// Discriminant values are stable — never change an existing value. +/// New variants must always be appended at the end with the next integer. +/// See `contracts/ERROR_CODES.md` for the full reference table. +#[contracterror] #[derive(Clone, Debug, PartialEq)] pub enum ContractError { - ContractPaused, - Unauthorized, - TimelockNotExpired, + AlreadyInitialized = 1, + Unauthorized = 2, + TradeNotFound = 3, + WrongStatus = 4, + TradeExpired = 5, + InsufficientFunds = 6, + InvalidExpiry = 7, + AlreadyDisputed = 8, + ContractPaused = 9, + TimelockNotExpired = 10, + UnsupportedToken = 11, + InvalidAmount = 12, + FillAlreadyProcessed = 13, + NotAParty = 14, } // --------------------------------------------------------------------------- @@ -90,22 +106,23 @@ fn topic_unpaused() -> Symbol { symbol_short!("unpaused") } // Internal helpers // --------------------------------------------------------------------------- -fn require_not_paused(env: &Env) { +fn require_not_paused(env: &Env) -> Result<(), ContractError> { let paused: bool = env .storage() .instance() .get(&DataKey::Paused) .unwrap_or(false); if paused { - panic!("ContractPaused"); + return Err(ContractError::ContractPaused); } + Ok(()) } -fn get_admin(env: &Env) -> Address { +fn get_admin(env: &Env) -> Result { env.storage() .instance() .get(&DataKey::Admin) - .expect("not initialised") + .ok_or(ContractError::Unauthorized) } // --------------------------------------------------------------------------- @@ -121,9 +138,13 @@ impl EscrowContract { // Initialise // ----------------------------------------------------------------------- - pub fn initialize(env: Env, admin: Address, allowed_tokens: Vec
) { + pub fn initialize( + env: Env, + admin: Address, + allowed_tokens: Vec
, + ) -> Result<(), ContractError> { if env.storage().instance().has(&DataKey::Admin) { - panic!("already initialised"); + return Err(ContractError::AlreadyInitialized); } admin.require_auth(); env.storage().instance().set(&DataKey::Admin, &admin); @@ -136,6 +157,7 @@ impl EscrowContract { } // Bump instance TTL so it survives long-running trades env.storage().instance().extend_ttl(17_280, 17_280 * 30); + Ok(()) } // ----------------------------------------------------------------------- @@ -144,26 +166,28 @@ impl EscrowContract { /// Halts all state-mutating operations. Only callable by admin. /// Emits a `topics: ["contract", "paused"]` event. - pub fn pause(env: Env) { - let admin = get_admin(&env); + pub fn pause(env: Env) -> Result<(), ContractError> { + let admin = get_admin(&env)?; admin.require_auth(); env.storage().instance().set(&DataKey::Paused, &true); env.events() .publish((topic_contract(), topic_paused()), ()); + Ok(()) } /// Resumes normal operations. Only callable by admin. /// Emits a `topics: ["contract", "unpaused"]` event. - pub fn unpause(env: Env) { - let admin = get_admin(&env); + pub fn unpause(env: Env) -> Result<(), ContractError> { + let admin = get_admin(&env)?; admin.require_auth(); env.storage().instance().set(&DataKey::Paused, &false); env.events() .publish((topic_contract(), topic_unpaused()), ()); + Ok(()) } // ----------------------------------------------------------------------- @@ -177,21 +201,25 @@ impl EscrowContract { amount: i128, asset_type: Symbol, expires_at: u64, - ) -> u64 { + ) -> Result { seller.require_auth(); - require_not_paused(&env); + require_not_paused(&env)?; - if !env.storage().instance().has(&DataKey::AllowedToken(token.clone())) { - panic!("unsupported token"); + if !env + .storage() + .instance() + .has(&DataKey::AllowedToken(token.clone())) + { + return Err(ContractError::UnsupportedToken); } if amount <= 0 { - panic!("amount must be positive"); + return Err(ContractError::InvalidAmount); } let now = env.ledger().timestamp(); if expires_at <= now { - panic!("expires_at must be in the future"); + return Err(ContractError::InvalidExpiry); } let id: u64 = env @@ -213,28 +241,39 @@ impl EscrowContract { expires_at, }; - env.storage().persistent().set(&DataKey::Trade(id), &trade); - env.storage().persistent().extend_ttl(&DataKey::Trade(id), 17_280, 17_280 * 30); + env.storage() + .persistent() + .set(&DataKey::Trade(id), &trade); + env.storage() + .persistent() + .extend_ttl(&DataKey::Trade(id), 17_280, 17_280 * 30); - env.events().publish((topic_created(), asset_type), (id, seller, amount)); + env.events() + .publish((topic_created(), asset_type), (id, seller, amount)); - id + Ok(id) } // ----------------------------------------------------------------------- // Admin functions // ----------------------------------------------------------------------- - pub fn add_allowed_token(env: Env, token: Address) { - let admin: Address = env.storage().instance().get(&DataKey::Admin).expect("not initialised"); + pub fn add_allowed_token(env: Env, token: Address) -> Result<(), ContractError> { + let admin = get_admin(&env)?; admin.require_auth(); - env.storage().instance().set(&DataKey::AllowedToken(token), &true); + env.storage() + .instance() + .set(&DataKey::AllowedToken(token), &true); + Ok(()) } - pub fn remove_allowed_token(env: Env, token: Address) { - let admin: Address = env.storage().instance().get(&DataKey::Admin).expect("not initialised"); + pub fn remove_allowed_token(env: Env, token: Address) -> Result<(), ContractError> { + let admin = get_admin(&env)?; admin.require_auth(); - env.storage().instance().remove(&DataKey::AllowedToken(token)); + env.storage() + .instance() + .remove(&DataKey::AllowedToken(token)); + Ok(()) } // ----------------------------------------------------------------------- @@ -243,37 +282,42 @@ impl EscrowContract { /// Locks the buyer's funds into the contract for a specific trade. /// - /// Transfers `trade.amount` tokens from `buyer` → contract. - /// Sets trade status to `Locked`. - pub fn deposit_to_escrow(env: Env, buyer: Address, trade_id: u64, fill_amount: i128) { + /// Transfers `fill_amount` tokens from `buyer` → contract. + /// Sets trade status to `Locked` when fully filled, `PartiallyFilled` otherwise. + pub fn deposit_to_escrow( + env: Env, + buyer: Address, + trade_id: u64, + fill_amount: i128, + ) -> Result<(), ContractError> { buyer.require_auth(); - require_not_paused(&env); + require_not_paused(&env)?; let mut trade: TradeOffer = env .storage() .persistent() .get(&DataKey::Trade(trade_id)) - .expect("trade not found"); + .ok_or(ContractError::TradeNotFound)?; if trade.status != TradeStatus::Open && trade.status != TradeStatus::PartiallyFilled { - panic!("trade is not open"); + return Err(ContractError::WrongStatus); } let now = env.ledger().timestamp(); if now >= trade.expires_at { - panic!("trade has expired"); + return Err(ContractError::TradeExpired); } if buyer == trade.seller { - panic!("seller cannot buy own trade"); + return Err(ContractError::Unauthorized); } if fill_amount <= 0 { - panic!("fill amount must be positive"); + return Err(ContractError::InvalidAmount); } if fill_amount > trade.total_amount - trade.filled_amount { - panic!("fill amount exceeds available amount"); + return Err(ContractError::InsufficientFunds); } let token_client = token::Client::new(&env, &trade.token); @@ -286,10 +330,19 @@ impl EscrowContract { trade.status = TradeStatus::PartiallyFilled; } - env.storage().persistent().set(&DataKey::Trade(trade_id), &trade); + env.storage() + .persistent() + .set(&DataKey::Trade(trade_id), &trade); - let fill_id = env.storage().instance().get(&DataKey::TradeFillCounter(trade_id)).unwrap_or(0u64) + 1; - env.storage().instance().set(&DataKey::TradeFillCounter(trade_id), &fill_id); + let fill_id = env + .storage() + .instance() + .get(&DataKey::TradeFillCounter(trade_id)) + .unwrap_or(0u64) + + 1; + env.storage() + .instance() + .set(&DataKey::TradeFillCounter(trade_id), &fill_id); let sub_escrow = SubEscrow { fill_id, @@ -298,9 +351,13 @@ impl EscrowContract { released: false, refunded: false, }; - env.storage().persistent().set(&DataKey::SubEscrow(trade_id, fill_id), &sub_escrow); + env.storage() + .persistent() + .set(&DataKey::SubEscrow(trade_id, fill_id), &sub_escrow); - env.events().publish((topic_locked(),), (trade_id, buyer)); + env.events() + .publish((topic_locked(),), (trade_id, buyer)); + Ok(()) } // ----------------------------------------------------------------------- @@ -311,32 +368,35 @@ impl EscrowContract { /// /// The admin address (set at `initialize`) must authorise this call via /// `require_auth()`. In production the admin is the platform server signing - /// key that verifies off-chain delivery before releasing escrow. In a more - /// decentralised future this role could move to a multi-sig or oracle contract. - pub fn release_payment(env: Env, trade_id: u64, fill_id: u64) { - require_not_paused(&env); + /// key that verifies off-chain delivery before releasing escrow. + pub fn release_payment( + env: Env, + trade_id: u64, + fill_id: u64, + ) -> Result<(), ContractError> { + require_not_paused(&env)?; - let admin = get_admin(&env); + let admin = get_admin(&env)?; admin.require_auth(); let mut trade: TradeOffer = env .storage() .persistent() .get(&DataKey::Trade(trade_id)) - .expect("trade not found"); + .ok_or(ContractError::TradeNotFound)?; if trade.status != TradeStatus::Locked && trade.status != TradeStatus::PartiallyFilled { - panic!("trade is not locked"); + return Err(ContractError::WrongStatus); } let mut sub_escrow: SubEscrow = env .storage() .persistent() .get(&DataKey::SubEscrow(trade_id, fill_id)) - .expect("fill not found"); + .ok_or(ContractError::TradeNotFound)?; if sub_escrow.released || sub_escrow.refunded { - panic!("fill already processed"); + return Err(ContractError::FillAlreadyProcessed); } let token_client = token::Client::new(&env, &trade.token); @@ -347,13 +407,23 @@ impl EscrowContract { ); sub_escrow.released = true; - env.storage().persistent().set(&DataKey::SubEscrow(trade_id, fill_id), &sub_escrow); + env.storage() + .persistent() + .set(&DataKey::SubEscrow(trade_id, fill_id), &sub_escrow); if trade.filled_amount == trade.total_amount { - let fill_count = env.storage().instance().get(&DataKey::TradeFillCounter(trade_id)).unwrap_or(0); + let fill_count = env + .storage() + .instance() + .get(&DataKey::TradeFillCounter(trade_id)) + .unwrap_or(0); let mut all_released = true; for i in 1..=fill_count { - if let Some(sub) = env.storage().persistent().get::<_, SubEscrow>(&DataKey::SubEscrow(trade_id, i)) { + if let Some(sub) = env + .storage() + .persistent() + .get::<_, SubEscrow>(&DataKey::SubEscrow(trade_id, i)) + { if !sub.released && !sub.refunded { all_released = false; break; @@ -362,44 +432,67 @@ impl EscrowContract { } if all_released { trade.status = TradeStatus::Completed; - env.storage().persistent().set(&DataKey::Trade(trade_id), &trade); + env.storage() + .persistent() + .set(&DataKey::Trade(trade_id), &trade); } } - env.events().publish((topic_completed(),), (trade_id, trade.seller.clone())); + env.events() + .publish((topic_completed(),), (trade_id, trade.seller.clone())); + Ok(()) } // ----------------------------------------------------------------------- // cancel_and_refund // ----------------------------------------------------------------------- - pub fn cancel_and_refund(env: Env, caller: Address, trade_id: u64) { - require_not_paused(&env); + pub fn cancel_and_refund( + env: Env, + caller: Address, + trade_id: u64, + ) -> Result<(), ContractError> { + require_not_paused(&env)?; caller.require_auth(); - let admin = get_admin(&env); + let admin = get_admin(&env)?; let is_admin = caller == admin; - let mut trade: TradeOffer = env.storage().persistent().get(&DataKey::Trade(trade_id)).expect("trade not found"); + let mut trade: TradeOffer = env + .storage() + .persistent() + .get(&DataKey::Trade(trade_id)) + .ok_or(ContractError::TradeNotFound)?; - if trade.status != TradeStatus::Locked && trade.status != TradeStatus::Disputed && trade.status != TradeStatus::PartiallyFilled { - panic!("trade cannot be refunded in its current state"); + if trade.status != TradeStatus::Locked + && trade.status != TradeStatus::Disputed + && trade.status != TradeStatus::PartiallyFilled + { + return Err(ContractError::WrongStatus); } let now = env.ledger().timestamp(); - let fill_count = env.storage().instance().get(&DataKey::TradeFillCounter(trade_id)).unwrap_or(0); + let fill_count = env + .storage() + .instance() + .get(&DataKey::TradeFillCounter(trade_id)) + .unwrap_or(0); let mut refunded_amount = 0; let mut caller_has_fills = false; let token_client = token::Client::new(&env, &trade.token); for i in 1..=fill_count { - if let Some(mut sub) = env.storage().persistent().get::<_, SubEscrow>(&DataKey::SubEscrow(trade_id, i)) { + if let Some(mut sub) = env + .storage() + .persistent() + .get::<_, SubEscrow>(&DataKey::SubEscrow(trade_id, i)) + { if !sub.released && !sub.refunded { let is_buyer = sub.buyer == caller; if is_admin || is_buyer { if is_buyer && !is_admin && now < trade.expires_at { - panic!("timelock has not expired yet"); + return Err(ContractError::TimelockNotExpired); } caller_has_fills = true; token_client.transfer( @@ -408,7 +501,9 @@ impl EscrowContract { &sub.amount, ); sub.refunded = true; - env.storage().persistent().set(&DataKey::SubEscrow(trade_id, i), &sub); + env.storage() + .persistent() + .set(&DataKey::SubEscrow(trade_id, i), &sub); refunded_amount += sub.amount; } } @@ -416,7 +511,7 @@ impl EscrowContract { } if !is_admin && !caller_has_fills { - panic!("only admin or buyer can cancel"); + return Err(ContractError::Unauthorized); } trade.filled_amount -= refunded_amount; @@ -429,30 +524,54 @@ impl EscrowContract { trade.status = TradeStatus::PartiallyFilled; } - env.storage().persistent().set(&DataKey::Trade(trade_id), &trade); - env.events().publish((topic_cancelled(),), (trade_id, caller)); + env.storage() + .persistent() + .set(&DataKey::Trade(trade_id), &trade); + env.events() + .publish((topic_cancelled(),), (trade_id, caller)); + Ok(()) } // ----------------------------------------------------------------------- // flag_dispute // ----------------------------------------------------------------------- - pub fn flag_dispute(env: Env, caller: Address, trade_id: u64) { - require_not_paused(&env); + pub fn flag_dispute( + env: Env, + caller: Address, + trade_id: u64, + ) -> Result<(), ContractError> { + require_not_paused(&env)?; caller.require_auth(); - let mut trade: TradeOffer = env.storage().persistent().get(&DataKey::Trade(trade_id)).expect("trade not found"); + let mut trade: TradeOffer = env + .storage() + .persistent() + .get(&DataKey::Trade(trade_id)) + .ok_or(ContractError::TradeNotFound)?; + + if trade.status == TradeStatus::Disputed { + return Err(ContractError::AlreadyDisputed); + } if trade.status != TradeStatus::Locked && trade.status != TradeStatus::PartiallyFilled { - panic!("only a Locked or PartiallyFilled trade can be disputed"); + return Err(ContractError::WrongStatus); } let mut is_party = caller == trade.seller; - + if !is_party { - let fill_count = env.storage().instance().get(&DataKey::TradeFillCounter(trade_id)).unwrap_or(0); + let fill_count = env + .storage() + .instance() + .get(&DataKey::TradeFillCounter(trade_id)) + .unwrap_or(0); for i in 1..=fill_count { - if let Some(sub) = env.storage().persistent().get::<_, SubEscrow>(&DataKey::SubEscrow(trade_id, i)) { + if let Some(sub) = env + .storage() + .persistent() + .get::<_, SubEscrow>(&DataKey::SubEscrow(trade_id, i)) + { if sub.buyer == caller { is_party = true; break; @@ -462,23 +581,27 @@ impl EscrowContract { } if !is_party { - panic!("only trade parties can flag a dispute"); + return Err(ContractError::NotAParty); } trade.status = TradeStatus::Disputed; - env.storage().persistent().set(&DataKey::Trade(trade_id), &trade); - env.events().publish((topic_disputed(),), (trade_id, caller)); + env.storage() + .persistent() + .set(&DataKey::Trade(trade_id), &trade); + env.events() + .publish((topic_disputed(),), (trade_id, caller)); + Ok(()) } // ----------------------------------------------------------------------- // View helpers (NOT blocked by paused flag) // ----------------------------------------------------------------------- - pub fn get_trade(env: Env, trade_id: u64) -> TradeOffer { + pub fn get_trade(env: Env, trade_id: u64) -> Result { env.storage() .persistent() .get(&DataKey::Trade(trade_id)) - .expect("trade not found") + .ok_or(ContractError::TradeNotFound) } pub fn trade_count(env: Env) -> u64 { @@ -488,11 +611,11 @@ impl EscrowContract { .unwrap_or(0u64) } - pub fn get_admin(env: Env) -> Address { + pub fn get_admin(env: Env) -> Result { env.storage() .instance() .get(&DataKey::Admin) - .expect("not initialised") + .ok_or(ContractError::Unauthorized) } /// Returns whether the contract is currently paused. @@ -517,7 +640,14 @@ mod test { Address, Env, }; - fn setup() -> (Env, EscrowContractClient<'static>, Address, Address, Address, Address) { + fn setup() -> ( + Env, + EscrowContractClient<'static>, + Address, + Address, + Address, + Address, + ) { let env = Env::default(); env.mock_all_auths(); @@ -542,13 +672,12 @@ mod test { } // ----------------------------------------------------------------------- - // Existing functional tests + // Existing functional tests (updated to use Result-returning functions) // ----------------------------------------------------------------------- #[test] fn test_create_listing() { let (env, client, _admin, seller, _buyer, token) = setup(); - env.ledger().with_mut(|l| l.timestamp = 1_000_000); let trade_id = client.create_listing( @@ -741,7 +870,7 @@ mod test { ); client.deposit_to_escrow(&buyer, &trade_id, &200_0000000i128); - + let buyer2 = Address::generate(&env); let sac = StellarAssetClient::new(&env, &token); sac.mint(&buyer2, &500_0000000i128); @@ -753,23 +882,6 @@ mod test { assert_eq!(trade.filled_amount, 500_0000000i128); } - #[test] - #[should_panic(expected = "fill amount exceeds available amount")] - fn test_over_fill_rejection() { - let (env, client, _admin, seller, buyer, token) = setup(); - env.ledger().with_mut(|l| l.timestamp = 1_000_000); - - let trade_id = client.create_listing( - &seller, - &token, - &500_0000000i128, - &symbol_short!("AIRTIME"), - &(1_000_000 + 86_400), - ); - - client.deposit_to_escrow(&buyer, &trade_id, &600_0000000i128); - } - #[test] fn test_release_payment() { let (env, client, _admin, seller, buyer, token) = setup(); @@ -818,24 +930,6 @@ mod test { assert_eq!(token_client.balance(&buyer), 10_000_0000000i128); } - #[test] - #[should_panic(expected = "timelock has not expired yet")] - fn test_cancel_before_expiry_fails() { - let (env, client, _admin, seller, buyer, token) = setup(); - env.ledger().with_mut(|l| l.timestamp = 1_000_000); - - let trade_id = client.create_listing( - &seller, - &token, - &500_0000000i128, - &symbol_short!("AIRTIME"), - &(1_000_000 + 86_400), - ); - client.deposit_to_escrow(&buyer, &trade_id, &500_0000000i128); - - client.cancel_and_refund(&buyer, &trade_id); - } - #[test] fn test_admin_cancels_immediately() { let (env, client, admin, seller, buyer, token) = setup(); @@ -850,7 +944,6 @@ mod test { ); client.deposit_to_escrow(&buyer, &trade_id, &500_0000000i128); - // Admin cancels immediately before timelock expiry client.cancel_and_refund(&admin, &trade_id); let trade = client.get_trade(&trade_id); @@ -861,84 +954,73 @@ mod test { assert_eq!(token_client.balance(&buyer), 10_000_0000000i128); } - #[test] - #[should_panic(expected = "only admin or buyer can cancel")] - fn test_seller_cancel_fails() { - let (env, client, _admin, seller, buyer, token) = setup(); - env.ledger().with_mut(|l| l.timestamp = 1_000_000); - - let trade_id = client.create_listing( - &seller, - &token, - &500_0000000i128, - &symbol_short!("AIRTIME"), - &(1_000_000 + 86_400), - ); - client.deposit_to_escrow(&buyer, &trade_id, &500_0000000i128); - - // Seller attempts to cancel and refund - client.cancel_and_refund(&seller, &trade_id); - } - // ----------------------------------------------------------------------- - // Pausability tests + // Error variant tests — assert typed ContractError is returned // ----------------------------------------------------------------------- #[test] - fn test_pause_and_unpause() { - let (_env, client, _admin, _seller, _buyer, _token) = setup(); - - // Initially not paused - assert!(!client.is_paused()); + fn test_err_already_initialized() { + let (env, client, admin, _seller, _buyer, token) = setup(); + // setup() already called initialize; call it again + let allowed = vec![&env, token.clone()]; + let result = client.try_initialize(&admin, &allowed); + assert_eq!(result, Ok(Err(ContractError::AlreadyInitialized))); + } - // Pause - client.pause(); - assert!(client.is_paused()); + #[test] + fn test_err_trade_not_found() { + let (_env, client, _admin, _seller, _buyer, _token) = setup(); + let result = client.try_get_trade(&999u64); + assert_eq!(result, Ok(Err(ContractError::TradeNotFound))); + } - // Unpause - client.unpause(); - assert!(!client.is_paused()); + #[test] + fn test_err_unsupported_token() { + let (env, client, _admin, seller, _buyer, _token) = setup(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + // Generate an address that was never added to the allowed list + let bad_token = Address::generate(&env); + let result = client.try_create_listing( + &seller, + &bad_token, + &100_0000000i128, + &symbol_short!("AIRTIME"), + &(1_000_000 + 86_400), + ); + assert_eq!(result, Ok(Err(ContractError::UnsupportedToken))); } #[test] - #[should_panic(expected = "ContractPaused")] - fn test_create_listing_blocked_when_paused() { + fn test_err_invalid_amount_zero() { let (env, client, _admin, seller, _buyer, token) = setup(); env.ledger().with_mut(|l| l.timestamp = 1_000_000); - - client.pause(); - - client.create_listing( + let result = client.try_create_listing( &seller, &token, - &500_0000000i128, + &0i128, &symbol_short!("AIRTIME"), &(1_000_000 + 86_400), ); + assert_eq!(result, Ok(Err(ContractError::InvalidAmount))); } #[test] - #[should_panic(expected = "ContractPaused")] - fn test_deposit_to_escrow_blocked_when_paused() { - let (env, client, _admin, seller, buyer, token) = setup(); + fn test_err_invalid_expiry() { + let (env, client, _admin, seller, _buyer, token) = setup(); env.ledger().with_mut(|l| l.timestamp = 1_000_000); - - let trade_id = client.create_listing( + // expires_at in the past + let result = client.try_create_listing( &seller, &token, - &500_0000000i128, + &100_0000000i128, &symbol_short!("AIRTIME"), - &(1_000_000 + 86_400), + &999_999u64, ); - - client.pause(); - - client.deposit_to_escrow(&buyer, &trade_id, &500_0000000i128); + assert_eq!(result, Ok(Err(ContractError::InvalidExpiry))); } #[test] - #[should_panic(expected = "ContractPaused")] - fn test_release_payment_blocked_when_paused() { + fn test_err_wrong_status_deposit_on_completed_trade() { let (env, client, _admin, seller, buyer, token) = setup(); env.ledger().with_mut(|l| l.timestamp = 1_000_000); @@ -950,15 +1032,17 @@ mod test { &(1_000_000 + 86_400), ); client.deposit_to_escrow(&buyer, &trade_id, &500_0000000i128); - - client.pause(); - client.release_payment(&trade_id, &1); + // trade is now Completed — depositing again should fail + let buyer2 = Address::generate(&env); + let sac = StellarAssetClient::new(&env, &token); + sac.mint(&buyer2, &500_0000000i128); + let result = client.try_deposit_to_escrow(&buyer2, &trade_id, &100_0000000i128); + assert_eq!(result, Ok(Err(ContractError::WrongStatus))); } #[test] - #[should_panic(expected = "ContractPaused")] - fn test_cancel_and_refund_blocked_when_paused() { + fn test_err_trade_expired() { let (env, client, _admin, seller, buyer, token) = setup(); env.ledger().with_mut(|l| l.timestamp = 1_000_000); @@ -969,19 +1053,15 @@ mod test { &symbol_short!("AIRTIME"), &(1_000_000 + 86_400), ); - client.deposit_to_escrow(&buyer, &trade_id, &500_0000000i128); - - // Advance past expiry + // Advance time past expiry env.ledger().with_mut(|l| l.timestamp = 1_000_000 + 86_401); - client.pause(); - - client.cancel_and_refund(&buyer, &trade_id); + let result = client.try_deposit_to_escrow(&buyer, &trade_id, &500_0000000i128); + assert_eq!(result, Ok(Err(ContractError::TradeExpired))); } #[test] - #[should_panic(expected = "ContractPaused")] - fn test_flag_dispute_blocked_when_paused() { + fn test_err_insufficient_funds_overfill() { let (env, client, _admin, seller, buyer, token) = setup(); env.ledger().with_mut(|l| l.timestamp = 1_000_000); @@ -992,15 +1072,13 @@ mod test { &symbol_short!("AIRTIME"), &(1_000_000 + 86_400), ); - client.deposit_to_escrow(&buyer, &trade_id, &500_0000000i128); - - client.pause(); - client.flag_dispute(&buyer, &trade_id); + let result = client.try_deposit_to_escrow(&buyer, &trade_id, &600_0000000i128); + assert_eq!(result, Ok(Err(ContractError::InsufficientFunds))); } #[test] - fn test_read_only_views_not_blocked_when_paused() { + fn test_err_wrong_status_release_on_open_trade() { let (env, client, _admin, seller, _buyer, token) = setup(); env.ledger().with_mut(|l| l.timestamp = 1_000_000); @@ -1011,32 +1089,36 @@ mod test { &symbol_short!("AIRTIME"), &(1_000_000 + 86_400), ); + // No deposit — trade is still Open + let result = client.try_release_payment(&trade_id, &1); + assert_eq!(result, Ok(Err(ContractError::WrongStatus))); + } - client.pause(); - - // These should all succeed even while paused - let trade = client.get_trade(&trade_id); - assert_eq!(trade.id, trade_id); - - let count = client.trade_count(); - assert_eq!(count, 1); - - let admin = client.get_admin(); - assert!(!admin.to_string().is_empty()); + #[test] + fn test_err_fill_already_processed() { + let (env, client, _admin, seller, buyer, token) = setup(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); - assert!(client.is_paused()); + let trade_id = client.create_listing( + &seller, + &token, + &500_0000000i128, + &symbol_short!("DATA"), + &(1_000_000 + 86_400), + ); + client.deposit_to_escrow(&buyer, &trade_id, &500_0000000i128); + // Release fill #1 once + client.release_payment(&trade_id, &1); + // Release same fill again — should fail + let result = client.try_release_payment(&trade_id, &1); + assert_eq!(result, Ok(Err(ContractError::FillAlreadyProcessed))); } #[test] - fn test_operations_resume_after_unpause() { + fn test_err_timelock_not_expired() { let (env, client, _admin, seller, buyer, token) = setup(); env.ledger().with_mut(|l| l.timestamp = 1_000_000); - // Pause and then unpause - client.pause(); - client.unpause(); - - // Should be able to create a listing again let trade_id = client.create_listing( &seller, &token, @@ -1044,80 +1126,94 @@ mod test { &symbol_short!("AIRTIME"), &(1_000_000 + 86_400), ); - - // And deposit client.deposit_to_escrow(&buyer, &trade_id, &500_0000000i128); - - let trade = client.get_trade(&trade_id); - assert_eq!(trade.status, TradeStatus::Locked); + // Buyer tries to cancel before expiry + let result = client.try_cancel_and_refund(&buyer, &trade_id); + assert_eq!(result, Ok(Err(ContractError::TimelockNotExpired))); } #[test] - fn test_release_payment_admin_on_locked_trade() { - let (env, client, admin, seller, buyer, token) = setup(); + fn test_err_unauthorized_seller_cancel() { + let (env, client, _admin, seller, buyer, token) = setup(); env.ledger().with_mut(|l| l.timestamp = 1_000_000); let trade_id = client.create_listing( &seller, &token, &500_0000000i128, - &symbol_short!("DATA"), + &symbol_short!("AIRTIME"), &(1_000_000 + 86_400), ); client.deposit_to_escrow(&buyer, &trade_id, &500_0000000i128); + // Seller is not a buyer and not admin — should get Unauthorized + let result = client.try_cancel_and_refund(&seller, &trade_id); + assert_eq!(result, Ok(Err(ContractError::Unauthorized))); + } - let trade_before = client.get_trade(&trade_id); - assert_eq!(trade_before.status, TradeStatus::Locked); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &client.mock_invoke(&client.release_payment, (&trade_id, &1u64)), - }]); - - client.release_payment(&trade_id, &1); - - let trade = client.get_trade(&trade_id); - assert_eq!(trade.status, TradeStatus::Completed); + #[test] + fn test_err_already_disputed() { + let (env, client, _admin, seller, buyer, token) = setup(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); - let token_client = TokenClient::new(&env, &token); - assert_eq!(token_client.balance(&seller), 500_0000000i128); + let trade_id = client.create_listing( + &seller, + &token, + &500_0000000i128, + &symbol_short!("AIRTIME"), + &(1_000_000 + 86_400), + ); + client.deposit_to_escrow(&buyer, &trade_id, &500_0000000i128); + // First flag + client.flag_dispute(&seller, &trade_id); + // Second flag on already-Disputed trade + let result = client.try_flag_dispute(&buyer, &trade_id); + assert_eq!(result, Ok(Err(ContractError::AlreadyDisputed))); } #[test] - #[should_panic(expected = "HostError: Error(Auth, InvalidAction)")] - fn test_release_payment_non_admin_rejected() { + fn test_err_not_a_party() { let (env, client, _admin, seller, buyer, token) = setup(); env.ledger().with_mut(|l| l.timestamp = 1_000_000); - let non_admin = Address::generate(&env); - let trade_id = client.create_listing( &seller, &token, &500_0000000i128, - &symbol_short!("DATA"), + &symbol_short!("AIRTIME"), &(1_000_000 + 86_400), ); client.deposit_to_escrow(&buyer, &trade_id, &500_0000000i128); - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &non_admin, - invoke: &client.mock_invoke(&client.release_payment, (&trade_id, &1u64)), - }]); + let stranger = Address::generate(&env); + let result = client.try_flag_dispute(&stranger, &trade_id); + assert_eq!(result, Ok(Err(ContractError::NotAParty))); + } - client.release_payment(&trade_id, &1); + #[test] + fn test_err_contract_paused() { + let (env, client, _admin, seller, _buyer, token) = setup(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + + client.pause(); + + let result = client.try_create_listing( + &seller, + &token, + &500_0000000i128, + &symbol_short!("AIRTIME"), + &(1_000_000 + 86_400), + ); + assert_eq!(result, Ok(Err(ContractError::ContractPaused))); } #[test] - #[should_panic(expected = "not initialised")] - fn test_pause_fails_if_not_initialised() { + fn test_err_unauthorized_get_admin_uninitialised() { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register_contract(None, EscrowContract); let client = EscrowContractClient::new(&env, &contract_id); - - // Calling pause without initializing should panic - client.pause(); + // Contract not initialised — get_admin should return Unauthorized + let result = client.try_get_admin(); + assert_eq!(result, Ok(Err(ContractError::Unauthorized))); } } diff --git a/contracts/marketplace/src/lib.rs b/contracts/marketplace/src/lib.rs index e797964..756f3b4 100644 --- a/contracts/marketplace/src/lib.rs +++ b/contracts/marketplace/src/lib.rs @@ -2,7 +2,7 @@ #![allow(clippy::too_many_arguments)] use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, + contract, contractimpl, contracttype, contracterror, symbol_short, token, Address, Env, Symbol, }; @@ -61,6 +61,34 @@ pub struct Reputation { pub total_volume: i128, } +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/// Standardised contract error enum. +/// +/// Discriminant values are stable — never change an existing value. +/// New variants must always be appended at the end with the next integer. +/// See `contracts/ERROR_CODES.md` for the full reference table. +#[contracterror] +#[derive(Clone, Debug, PartialEq)] +pub enum ContractError { + AlreadyInitialized = 1, + Unauthorized = 2, + TradeNotFound = 3, + WrongStatus = 4, + TradeExpired = 5, + InsufficientFunds = 6, + InvalidExpiry = 7, + AlreadyDisputed = 8, + ContractPaused = 9, + TimelockNotExpired = 10, + UnsupportedToken = 11, + InvalidAmount = 12, + FillAlreadyProcessed = 13, + NotAParty = 14, +} + // --------------------------------------------------------------------------- // Events // --------------------------------------------------------------------------- @@ -76,22 +104,23 @@ fn topic_unpaused() -> Symbol { symbol_short!("unpaused") } // Internal helpers // --------------------------------------------------------------------------- -fn require_not_paused(env: &Env) { +fn require_not_paused(env: &Env) -> Result<(), ContractError> { let paused: bool = env .storage() .instance() .get(&DataKey::Paused) .unwrap_or(false); if paused { - panic!("ContractPaused"); + return Err(ContractError::ContractPaused); } + Ok(()) } -fn get_admin(env: &Env) -> Address { +fn get_admin(env: &Env) -> Result { env.storage() .instance() .get(&DataKey::Admin) - .expect("not initialised") + .ok_or(ContractError::Unauthorized) } fn update_reputation(env: &Env, seller: &Address, volume: i128, disputed: bool) { @@ -134,16 +163,17 @@ impl MarketplaceContract { // ----------------------------------------------------------------------- /// Sets the admin address and seeds the listing counter. - /// Can only be called once (panics if already initialised). - pub fn initialize(env: Env, admin: Address) { + /// Returns `Err(ContractError::AlreadyInitialized)` if called more than once. + pub fn initialize(env: Env, admin: Address) -> Result<(), ContractError> { if env.storage().instance().has(&DataKey::Admin) { - panic!("already initialised"); + return Err(ContractError::AlreadyInitialized); } admin.require_auth(); env.storage().instance().set(&DataKey::Admin, &admin); env.storage().instance().set(&DataKey::ListingCounter, &0u64); env.storage().instance().set(&DataKey::Paused, &false); env.storage().instance().extend_ttl(17_280, 17_280 * 30); + Ok(()) } // ----------------------------------------------------------------------- @@ -152,26 +182,28 @@ impl MarketplaceContract { /// Halts all state-mutating operations. Only callable by admin. /// Emits a `topics: ["contract", "paused"]` event. - pub fn pause(env: Env) { - let admin = get_admin(&env); + pub fn pause(env: Env) -> Result<(), ContractError> { + let admin = get_admin(&env)?; admin.require_auth(); env.storage().instance().set(&DataKey::Paused, &true); env.events() .publish((topic_contract(), topic_paused()), ()); + Ok(()) } /// Resumes normal operations. Only callable by admin. /// Emits a `topics: ["contract", "unpaused"]` event. - pub fn unpause(env: Env) { - let admin = get_admin(&env); + pub fn unpause(env: Env) -> Result<(), ContractError> { + let admin = get_admin(&env)?; admin.require_auth(); env.storage().instance().set(&DataKey::Paused, &false); env.events() .publish((topic_contract(), topic_unpaused()), ()); + Ok(()) } // ----------------------------------------------------------------------- @@ -190,20 +222,20 @@ impl MarketplaceContract { asset_type: Symbol, quantity: i128, expires_at: u64, - ) -> u64 { - require_not_paused(&env); + ) -> Result { + require_not_paused(&env)?; seller.require_auth(); if price <= 0 { - panic!("price must be positive"); + return Err(ContractError::InvalidAmount); } if quantity <= 0 { - panic!("quantity must be positive"); + return Err(ContractError::InvalidAmount); } let now = env.ledger().timestamp(); if expires_at <= now { - panic!("expires_at must be in the future"); + return Err(ContractError::InvalidExpiry); } let id: u64 = env @@ -237,7 +269,7 @@ impl MarketplaceContract { env.events() .publish((topic_listed(), asset_type), (id, seller, price, quantity)); - id + Ok(id) } // ----------------------------------------------------------------------- @@ -248,27 +280,31 @@ impl MarketplaceContract { /// /// Transfers `listing.price` tokens from `buyer` → contract. /// Sets listing status to `Sold`. - pub fn deposit_to_escrow(env: Env, buyer: Address, listing_id: u64) { - require_not_paused(&env); + pub fn deposit_to_escrow( + env: Env, + buyer: Address, + listing_id: u64, + ) -> Result<(), ContractError> { + require_not_paused(&env)?; buyer.require_auth(); let mut listing: Listing = env .storage() .persistent() .get(&DataKey::Listing(listing_id)) - .expect("listing not found"); + .ok_or(ContractError::TradeNotFound)?; if listing.status != ListingStatus::Active { - panic!("listing is not active"); + return Err(ContractError::WrongStatus); } let now = env.ledger().timestamp(); if now >= listing.expires_at { - panic!("listing has expired"); + return Err(ContractError::TradeExpired); } if buyer == listing.seller { - panic!("seller cannot buy own listing"); + return Err(ContractError::Unauthorized); } let token_client = token::Client::new(&env, &listing.token); @@ -282,6 +318,7 @@ impl MarketplaceContract { env.events() .publish((topic_sold(),), (listing_id, buyer, listing.price)); + Ok(()) } // ----------------------------------------------------------------------- @@ -292,20 +329,20 @@ impl MarketplaceContract { /// Updates the seller's reputation on-chain. /// /// Only the admin account can call this. - pub fn release_payment(env: Env, listing_id: u64) { - require_not_paused(&env); + pub fn release_payment(env: Env, listing_id: u64) -> Result<(), ContractError> { + require_not_paused(&env)?; - let admin = get_admin(&env); + let admin = get_admin(&env)?; admin.require_auth(); let listing: Listing = env .storage() .persistent() .get(&DataKey::Listing(listing_id)) - .expect("listing not found"); + .ok_or(ContractError::TradeNotFound)?; if listing.status != ListingStatus::Sold { - panic!("listing is not in Sold state"); + return Err(ContractError::WrongStatus); } let token_client = token::Client::new(&env, &listing.token); @@ -319,6 +356,7 @@ impl MarketplaceContract { env.events() .publish((topic_sold(),), (listing_id, listing.seller, listing.price)); + Ok(()) } // ----------------------------------------------------------------------- @@ -328,20 +366,24 @@ impl MarketplaceContract { /// Returns escrowed funds to the buyer and marks the listing Cancelled. /// /// Only admin can call this directly in the marketplace contract. - pub fn cancel_and_refund(env: Env, buyer: Address, listing_id: u64) { - require_not_paused(&env); + pub fn cancel_and_refund( + env: Env, + buyer: Address, + listing_id: u64, + ) -> Result<(), ContractError> { + require_not_paused(&env)?; - let admin = get_admin(&env); + let admin = get_admin(&env)?; admin.require_auth(); let mut listing: Listing = env .storage() .persistent() .get(&DataKey::Listing(listing_id)) - .expect("listing not found"); + .ok_or(ContractError::TradeNotFound)?; if listing.status != ListingStatus::Sold { - panic!("listing cannot be refunded in its current state"); + return Err(ContractError::WrongStatus); } let token_client = token::Client::new(&env, &listing.token); @@ -361,6 +403,7 @@ impl MarketplaceContract { env.events() .publish((topic_cancelled(),), (listing_id, buyer)); + Ok(()) } // ----------------------------------------------------------------------- @@ -370,20 +413,24 @@ impl MarketplaceContract { /// Admin resolves a disputed listing, transferring funds to the /// specified `recipient` (either the buyer for a refund or the seller /// for a release). - pub fn resolve_dispute(env: Env, listing_id: u64, recipient: Address) { - require_not_paused(&env); + pub fn resolve_dispute( + env: Env, + listing_id: u64, + recipient: Address, + ) -> Result<(), ContractError> { + require_not_paused(&env)?; - let admin = get_admin(&env); + let admin = get_admin(&env)?; admin.require_auth(); let mut listing: Listing = env .storage() .persistent() .get(&DataKey::Listing(listing_id)) - .expect("listing not found"); + .ok_or(ContractError::TradeNotFound)?; if listing.status != ListingStatus::Sold { - panic!("only a Sold listing can have a dispute resolved"); + return Err(ContractError::WrongStatus); } let token_client = token::Client::new(&env, &listing.token); @@ -405,6 +452,7 @@ impl MarketplaceContract { env.events() .publish((topic_cancelled(),), (listing_id, recipient)); + Ok(()) } // ----------------------------------------------------------------------- @@ -412,11 +460,11 @@ impl MarketplaceContract { // ----------------------------------------------------------------------- /// Returns a listing by ID. - pub fn get_listing(env: Env, listing_id: u64) -> Listing { + pub fn get_listing(env: Env, listing_id: u64) -> Result { env.storage() .persistent() .get(&DataKey::Listing(listing_id)) - .expect("listing not found") + .ok_or(ContractError::TradeNotFound) } /// Returns the reputation record for a given address. @@ -446,11 +494,11 @@ impl MarketplaceContract { } /// Returns the admin address. - pub fn get_admin(env: Env) -> Address { + pub fn get_admin(env: Env) -> Result { env.storage() .instance() .get(&DataKey::Admin) - .expect("not initialised") + .ok_or(ContractError::Unauthorized) } /// Returns whether the contract is currently paused. @@ -596,14 +644,13 @@ mod test { } #[test] - #[should_panic(expected = "ContractPaused")] fn test_create_listing_blocked_when_paused() { let (env, client, _admin, seller, _buyer, token) = setup(); env.ledger().with_mut(|l| l.timestamp = 1_000_000); client.pause(); - client.create_listing( + let result = client.try_create_listing( &seller, &token, &500_0000000i128, @@ -612,10 +659,10 @@ mod test { &1000i128, &(1_000_000 + 86_400), ); + assert_eq!(result, Ok(Err(ContractError::ContractPaused))); } #[test] - #[should_panic(expected = "ContractPaused")] fn test_deposit_to_escrow_blocked_when_paused() { let (env, client, _admin, seller, buyer, token) = setup(); env.ledger().with_mut(|l| l.timestamp = 1_000_000); @@ -632,11 +679,11 @@ mod test { client.pause(); - client.deposit_to_escrow(&buyer, &listing_id); + let result = client.try_deposit_to_escrow(&buyer, &listing_id); + assert_eq!(result, Ok(Err(ContractError::ContractPaused))); } #[test] - #[should_panic(expected = "ContractPaused")] fn test_release_payment_blocked_when_paused() { let (env, client, _admin, seller, buyer, token) = setup(); env.ledger().with_mut(|l| l.timestamp = 1_000_000); @@ -653,11 +700,12 @@ mod test { client.deposit_to_escrow(&buyer, &listing_id); client.pause(); - client.release_payment(&listing_id); + + let result = client.try_release_payment(&listing_id); + assert_eq!(result, Ok(Err(ContractError::ContractPaused))); } #[test] - #[should_panic(expected = "ContractPaused")] fn test_cancel_and_refund_blocked_when_paused() { let (env, client, _admin, seller, buyer, token) = setup(); env.ledger().with_mut(|l| l.timestamp = 1_000_000); @@ -674,11 +722,12 @@ mod test { client.deposit_to_escrow(&buyer, &listing_id); client.pause(); - client.cancel_and_refund(&buyer, &listing_id); + + let result = client.try_cancel_and_refund(&buyer, &listing_id); + assert_eq!(result, Ok(Err(ContractError::ContractPaused))); } #[test] - #[should_panic(expected = "ContractPaused")] fn test_resolve_dispute_blocked_when_paused() { let (env, client, _admin, seller, buyer, token) = setup(); env.ledger().with_mut(|l| l.timestamp = 1_000_000); @@ -695,7 +744,9 @@ mod test { client.deposit_to_escrow(&buyer, &listing_id); client.pause(); - client.resolve_dispute(&listing_id, &buyer); + + let result = client.try_resolve_dispute(&listing_id, &buyer); + assert_eq!(result, Ok(Err(ContractError::ContractPaused))); } #[test] @@ -718,27 +769,21 @@ mod test { client.pause(); - // get_listing should work while paused let listing = client.get_listing(&listing_id); assert_eq!(listing.id, listing_id); - // get_reputation should work while paused let rep = client.get_reputation(&seller); assert_eq!(rep.completed_trades, 1); - // balance should work while paused let bal = client.balance(&token); - assert_eq!(bal, 0i128); // funds were released + assert_eq!(bal, 0i128); - // listing_count should work while paused let count = client.listing_count(); assert_eq!(count, 1); - // get_admin should work while paused let admin_addr = client.get_admin(); assert!(!admin_addr.to_string().is_empty()); - // is_paused should always work assert!(client.is_paused()); } @@ -766,15 +811,181 @@ mod test { assert_eq!(listing.status, ListingStatus::Sold); } + // ----------------------------------------------------------------------- + // Error variant tests — assert typed ContractError is returned + // ----------------------------------------------------------------------- + + #[test] + fn test_err_already_initialized() { + let (_env, client, admin, _seller, _buyer, _token) = setup(); + // setup() already initialised — call again + let result = client.try_initialize(&admin); + assert_eq!(result, Ok(Err(ContractError::AlreadyInitialized))); + } + #[test] - #[should_panic(expected = "not initialised")] - fn test_pause_fails_if_not_initialised() { + fn test_err_unauthorized_uninitialised_pause() { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register_contract(None, MarketplaceContract); let client = MarketplaceContractClient::new(&env, &contract_id); + // Contract not initialised — pause should fail with Unauthorized + let result = client.try_pause(); + assert_eq!(result, Ok(Err(ContractError::Unauthorized))); + } - client.pause(); + #[test] + fn test_err_trade_not_found() { + let (_env, client, _admin, _seller, _buyer, _token) = setup(); + let result = client.try_get_listing(&999u64); + assert_eq!(result, Ok(Err(ContractError::TradeNotFound))); + } + + #[test] + fn test_err_invalid_amount_zero_price() { + let (env, client, _admin, seller, _buyer, token) = setup(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + let result = client.try_create_listing( + &seller, + &token, + &0i128, + &AssetCategory::Airtime, + &symbol_short!("MTN"), + &1000i128, + &(1_000_000 + 86_400), + ); + assert_eq!(result, Ok(Err(ContractError::InvalidAmount))); + } + + #[test] + fn test_err_invalid_amount_zero_quantity() { + let (env, client, _admin, seller, _buyer, token) = setup(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + let result = client.try_create_listing( + &seller, + &token, + &100_0000000i128, + &AssetCategory::Airtime, + &symbol_short!("MTN"), + &0i128, + &(1_000_000 + 86_400), + ); + assert_eq!(result, Ok(Err(ContractError::InvalidAmount))); + } + + #[test] + fn test_err_invalid_expiry() { + let (env, client, _admin, seller, _buyer, token) = setup(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + let result = client.try_create_listing( + &seller, + &token, + &100_0000000i128, + &AssetCategory::Data, + &symbol_short!("AIRTEL"), + &500i128, + &999_999u64, + ); + assert_eq!(result, Ok(Err(ContractError::InvalidExpiry))); + } + + #[test] + fn test_err_wrong_status_deposit_on_sold_listing() { + let (env, client, _admin, seller, buyer, token) = setup(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + + let listing_id = client.create_listing( + &seller, + &token, + &500_0000000i128, + &AssetCategory::Airtime, + &symbol_short!("MTN"), + &1000i128, + &(1_000_000 + 86_400), + ); + client.deposit_to_escrow(&buyer, &listing_id); + // Listing is now Sold — deposit again should fail + let buyer2 = Address::generate(&env); + let sac = StellarAssetClient::new(&env, &token); + sac.mint(&buyer2, &500_0000000i128); + let result = client.try_deposit_to_escrow(&buyer2, &listing_id); + assert_eq!(result, Ok(Err(ContractError::WrongStatus))); + } + + #[test] + fn test_err_trade_expired() { + let (env, client, _admin, seller, buyer, token) = setup(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + + let listing_id = client.create_listing( + &seller, + &token, + &500_0000000i128, + &AssetCategory::Data, + &symbol_short!("GLO"), + &200i128, + &(1_000_000 + 86_400), + ); + // Advance past expiry + env.ledger().with_mut(|l| l.timestamp = 1_000_000 + 86_401); + + let result = client.try_deposit_to_escrow(&buyer, &listing_id); + assert_eq!(result, Ok(Err(ContractError::TradeExpired))); + } + + #[test] + fn test_err_unauthorized_seller_buys_own_listing() { + let (env, client, _admin, seller, _buyer, token) = setup(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + + let listing_id = client.create_listing( + &seller, + &token, + &500_0000000i128, + &AssetCategory::Airtime, + &symbol_short!("MTN"), + &1000i128, + &(1_000_000 + 86_400), + ); + let result = client.try_deposit_to_escrow(&seller, &listing_id); + assert_eq!(result, Ok(Err(ContractError::Unauthorized))); + } + + #[test] + fn test_err_wrong_status_release_on_active_listing() { + let (env, client, _admin, seller, _buyer, token) = setup(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + + let listing_id = client.create_listing( + &seller, + &token, + &500_0000000i128, + &AssetCategory::Airtime, + &symbol_short!("MTN"), + &1000i128, + &(1_000_000 + 86_400), + ); + // No deposit — still Active + let result = client.try_release_payment(&listing_id); + assert_eq!(result, Ok(Err(ContractError::WrongStatus))); + } + + #[test] + fn test_err_wrong_status_cancel_on_active_listing() { + let (env, client, _admin, seller, buyer, token) = setup(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + + let listing_id = client.create_listing( + &seller, + &token, + &500_0000000i128, + &AssetCategory::Data, + &symbol_short!("AIRTEL"), + &500i128, + &(1_000_000 + 86_400), + ); + // Listing is Active, not Sold + let result = client.try_cancel_and_refund(&buyer, &listing_id); + assert_eq!(result, Ok(Err(ContractError::WrongStatus))); } } diff --git a/contracts/readme.md b/contracts/readme.md index 9f06b85..ae84c34 100644 --- a/contracts/readme.md +++ b/contracts/readme.md @@ -13,6 +13,8 @@ Soroban (Rust) smart contracts for the AirFlex P2P airtime/data marketplace on t | `escrow` | Trustless escrow for P2P trades (deposit, release, refund) | | `marketplace` | On-chain listing registry with seller reputation tracking | +See [ERROR_CODES.md](./ERROR_CODES.md) for the full list of typed contract error codes, their numeric values, and the conditions that trigger them. + --- ## Deployed Addresses diff --git a/server/src/services/contractErrors.ts b/server/src/services/contractErrors.ts new file mode 100644 index 0000000..e1d11f5 --- /dev/null +++ b/server/src/services/contractErrors.ts @@ -0,0 +1,320 @@ +/** + * contractErrors.ts + * + * Typed TypeScript error classes that mirror the on-chain `ContractError` enum + * defined in both Soroban contracts (escrow and marketplace). + * + * Discriminant values MUST match the Rust enum exactly. See + * `contracts/ERROR_CODES.md` for the canonical reference table. + * + * Usage: + * import { parseContractError, TradeNotFoundError } from "./contractErrors"; + * + * const parsed = parseContractError(err); + * if (parsed instanceof TradeNotFoundError) { ... } + */ + +// --------------------------------------------------------------------------- +// Base class +// --------------------------------------------------------------------------- + +/** + * Base class for all Soroban contract errors. + * Carries the numeric discriminant so callers can switch on `err.code`. + */ +export class ContractError extends Error { + constructor( + message: string, + public readonly code: number + ) { + super(message); + this.name = "ContractError"; + // Maintains correct prototype chain in transpiled ES5 environments + Object.setPrototypeOf(this, new.target.prototype); + } +} + +// --------------------------------------------------------------------------- +// Typed subclasses — one per ContractError variant +// --------------------------------------------------------------------------- + +/** Code 1 — `initialize` called on an already-initialised contract. */ +export class AlreadyInitializedError extends ContractError { + constructor() { + super("Contract has already been initialized", 1); + this.name = "AlreadyInitializedError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Code 2 — Caller is not the admin or authorised party. */ +export class UnauthorizedContractError extends ContractError { + constructor() { + super("Caller is not authorized to perform this action", 2); + this.name = "UnauthorizedContractError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Code 3 — No trade or listing exists for the given ID. */ +export class TradeNotFoundError extends ContractError { + constructor() { + super("Trade or listing not found", 3); + this.name = "TradeNotFoundError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Code 4 — Trade or listing is in a state that disallows the action. */ +export class WrongStatusError extends ContractError { + constructor() { + super("Trade or listing is in an invalid state for this operation", 4); + this.name = "WrongStatusError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Code 5 — Trade or listing expiry timestamp has passed. */ +export class TradeExpiredError extends ContractError { + constructor() { + super("Trade or listing has expired", 5); + this.name = "TradeExpiredError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Code 6 — Fill or price amount exceeds available balance. */ +export class InsufficientFundsError extends ContractError { + constructor() { + super("Fill amount exceeds the available amount in this trade", 6); + this.name = "InsufficientFundsError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Code 7 — Supplied `expires_at` is in the past or zero. */ +export class InvalidExpiryError extends ContractError { + constructor() { + super("Expiry timestamp must be in the future", 7); + this.name = "InvalidExpiryError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Code 8 — Dispute flag set on a trade already in Disputed status. */ +export class AlreadyDisputedError extends ContractError { + constructor() { + super("This trade is already marked as disputed", 8); + this.name = "AlreadyDisputedError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Code 9 — State-mutating call made while circuit-breaker is active. */ +export class ContractPausedError extends ContractError { + constructor() { + super("Contract is currently paused; no state mutations are allowed", 9); + this.name = "ContractPausedError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Code 10 — Buyer cancel attempted before the timelock window elapses. */ +export class TimelockNotExpiredError extends ContractError { + constructor() { + super("The timelock period has not yet elapsed; cancellation is not available", 10); + this.name = "TimelockNotExpiredError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Code 11 — Token address is not in the allowed-token list. */ +export class UnsupportedTokenError extends ContractError { + constructor() { + super("The specified token is not supported by this contract", 11); + this.name = "UnsupportedTokenError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Code 12 — Amount or fill amount is zero or negative. */ +export class InvalidAmountError extends ContractError { + constructor() { + super("Amount must be a positive integer", 12); + this.name = "InvalidAmountError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Code 13 — Sub-escrow fill has already been released or refunded. */ +export class FillAlreadyProcessedError extends ContractError { + constructor() { + super("This escrow fill has already been released or refunded", 13); + this.name = "FillAlreadyProcessedError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Code 14 — Caller is neither seller nor a buyer of the trade. */ +export class NotAPartyError extends ContractError { + constructor() { + super("Caller is not a party to this trade", 14); + this.name = "NotAPartyError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +/** Maps numeric error code → zero-argument constructor. */ +const ERROR_REGISTRY = new Map ContractError>([ + [1, AlreadyInitializedError], + [2, UnauthorizedContractError], + [3, TradeNotFoundError], + [4, WrongStatusError], + [5, TradeExpiredError], + [6, InsufficientFundsError], + [7, InvalidExpiryError], + [8, AlreadyDisputedError], + [9, ContractPausedError], + [10, TimelockNotExpiredError], + [11, UnsupportedTokenError], + [12, InvalidAmountError], + [13, FillAlreadyProcessedError], + [14, NotAPartyError], +]); + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +/** + * Attempts to extract a typed `ContractError` from an unknown error value. + * + * Extraction strategy (first match wins): + * 1. XDR `errorResult` structure from the Stellar SDK `SendTransactionResponse` + * when `response.status === "ERROR"`. + * 2. Pattern match on the error message string for `"Error(Contract, #N)"`, + * which Soroban SDK includes in some stringified error outputs. + * + * Returns `null` when the error is not a recognisable contract error + * (e.g. network failure, auth error, fee error). Callers MUST re-throw the + * original error when `null` is returned — never swallow it. + * + * This function never throws — all internal exceptions are caught and cause + * the function to fall through to the next strategy. + */ +export function parseContractError(err: unknown): ContractError | null { + // ------------------------------------------------------------------ + // Strategy 1: XDR errorResult on the Stellar SDK response object + // ------------------------------------------------------------------ + try { + // The Stellar SDK SendTransactionResponse shape when status === "ERROR" + // has `errorResult` as an xdr.TransactionResult. We stringify it and + // look for the contract error code pattern in the output, since XDR + // traversal APIs vary across SDK versions. + if (err !== null && typeof err === "object") { + const candidate = err as Record; + + // Direct errorResult on the response (status === "ERROR" path) + if (candidate["errorResult"] !== undefined) { + const code = extractCodeFromXdrResult(candidate["errorResult"]); + if (code !== null) { + return instantiate(code); + } + } + + // Error wrapping a response object (thrown after pollForResult FAILED) + if (candidate["response"] !== undefined) { + const resp = candidate["response"] as Record; + if (resp["errorResult"] !== undefined) { + const code = extractCodeFromXdrResult(resp["errorResult"]); + if (code !== null) { + return instantiate(code); + } + } + } + } + } catch { + // fall through to string strategy + } + + // ------------------------------------------------------------------ + // Strategy 2: Pattern match on the error message string + // Soroban SDK sometimes includes "Error(Contract, #N)" in messages. + // ------------------------------------------------------------------ + try { + const message = + err instanceof Error + ? err.message + : typeof err === "string" + ? err + : JSON.stringify(err); + + const code = extractCodeFromString(message); + if (code !== null) { + return instantiate(code); + } + } catch { + // fall through + } + + return null; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Attempts to extract a contract error code from an XDR object by + * stringifying it and applying the same regex as strategy 2. + * Returns null if the object does not contain a contract error code. + */ +function extractCodeFromXdrResult(xdrResult: unknown): number | null { + try { + const str = + typeof xdrResult === "string" + ? xdrResult + : JSON.stringify(xdrResult); + return extractCodeFromString(str); + } catch { + return null; + } +} + +/** + * Scans a string for the Soroban error pattern `Error(Contract, #N)` or + * `ContractError(N)` and returns the integer N. + * Returns null when no match is found. + */ +function extractCodeFromString(str: string): number | null { + // "Error(Contract, #3)" — standard Soroban SDK stringification + const sorobanMatch = /Error\(Contract,\s*#(\d+)\)/i.exec(str); + if (sorobanMatch?.[1] !== undefined) { + return parseInt(sorobanMatch[1], 10); + } + + // "ContractError(3)" — alternative format in some SDK versions + const altMatch = /ContractError\((\d+)\)/i.exec(str); + if (altMatch?.[1] !== undefined) { + return parseInt(altMatch[1], 10); + } + + return null; +} + +/** + * Looks up the registry for the given code and returns a new instance, + * or a generic `ContractError` with the unknown code if not found. + */ +function instantiate(code: number): ContractError { + const Ctor = ERROR_REGISTRY.get(code); + if (Ctor !== undefined) { + return new Ctor(); + } + // Unknown code — return a generic ContractError so callers still get a typed object + return new ContractError(`Unknown contract error (code ${code})`, code); +} diff --git a/server/src/services/stellar.ts b/server/src/services/stellar.ts index 1840fad..985d3fa 100644 --- a/server/src/services/stellar.ts +++ b/server/src/services/stellar.ts @@ -15,6 +15,7 @@ import { ESCROW_CONTRACT_ID, MARKETPLACE_CONTRACT_ID, } from "../config/contracts"; +import { parseContractError } from "./contractErrors"; // --------------------------------------------------------------------------- // OpenTelemetry tracing helpers @@ -257,7 +258,8 @@ export async function createListing(params: { const response = await sorobanServer.sendTransaction(preparedTx); if (response.status === "ERROR") { - throw new Error( + const parsed = parseContractError(response); + throw parsed ?? new Error( `Contract create_listing failed: ${JSON.stringify(response.errorResult)}` ); } @@ -331,7 +333,8 @@ export async function depositToEscrow(params: { const response = await sorobanServer.sendTransaction(preparedTx); if (response.status === "ERROR") { - throw new Error( + const parsed = parseContractError(response); + throw parsed ?? new Error( `Contract deposit_to_escrow failed: ${JSON.stringify(response.errorResult)}` ); } @@ -414,7 +417,8 @@ export async function releasePayment(contractTradeId: string): Promise { const response = await sorobanServer.sendTransaction(preparedTx); if (response.status === "ERROR") { - throw new Error( + const parsed = parseContractError(response); + throw parsed ?? new Error( `Contract release_payment failed: ${JSON.stringify(response.errorResult)}` ); } @@ -493,7 +497,8 @@ export async function resolveDispute(params: { const response = await sorobanServer.sendTransaction(preparedTx); if (response.status === "ERROR") { - throw new Error( + const parsed = parseContractError(response); + throw parsed ?? new Error( `Contract resolve_dispute failed: ${JSON.stringify(response.errorResult)}` ); } @@ -531,7 +536,8 @@ async function pollForResult(hash: string): Promise { } if (result.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { - throw new Error(`Transaction ${hash} failed on-chain`); + const parsed = parseContractError(result); + throw parsed ?? new Error(`Transaction ${hash} failed on-chain`); } // NOT_FOUND means still pending — keep polling } diff --git a/server/src/services/tradeVerification.ts b/server/src/services/tradeVerification.ts index cdacb70..2f5cea2 100644 --- a/server/src/services/tradeVerification.ts +++ b/server/src/services/tradeVerification.ts @@ -220,8 +220,9 @@ async function runVerificationWithRetry( }); } catch (err) { + const errorName = err instanceof Error ? err.constructor.name : "UnknownError"; const message = err instanceof Error ? err.message : String(err); - log("error", tradeId, `Attempt ${attempt} failed: ${message}`); + log("error", tradeId, `Attempt ${attempt} failed [${errorName}]: ${message}`); if (attempt < MAX_RETRIES) { // Exponential back-off with ±20 % jitter