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