diff --git a/contracts/dispute_resolution_contract/lib.rs b/contracts/dispute_resolution_contract/lib.rs index 69c8b01..90906ab 100644 --- a/contracts/dispute_resolution_contract/lib.rs +++ b/contracts/dispute_resolution_contract/lib.rs @@ -246,13 +246,19 @@ impl DisputeResolutionContract { .driver .unwrap_or_else(|| panic_with_error!(&env, SwiftChainError::ProviderNotFound)); - let reputation_addr = Self::get_identity_reputation_contract(env.clone()); - let reputation_client = IdentityReputationContractClient::new(&env, &reputation_addr); - reputation_client.decrease_reputation( - &env.current_contract_address(), - &driver, - &DISPUTE_REPUTATION_PENALTY, - ); + if let Some(reputation_addr) = env + .storage() + .instance() + .get::(&DataKey::IdentityReputationContract) + { + let reputation_client = + IdentityReputationContractClient::new(&env, &reputation_addr); + reputation_client.decrease_reputation( + &env.current_contract_address(), + &driver, + &DISPUTE_REPUTATION_PENALTY, + ); + } let escrow_addr = Self::get_escrow_contract(env.clone()); diff --git a/contracts/dispute_resolution_contract/test.rs b/contracts/dispute_resolution_contract/test.rs index f8293e1..15383ad 100644 --- a/contracts/dispute_resolution_contract/test.rs +++ b/contracts/dispute_resolution_contract/test.rs @@ -323,8 +323,9 @@ fn test_raise_dispute_delivered_exceeds_time_limit() { fn test_resolve_dispute_refund_sender_by_admin() { let (env, admin, sender, recipient, driver, delivery_id, escrow_id, dispute_client) = setup_test(); - // Setup mock delivery - let delivery_record = create_mock_delivery_record(&env, did(4), sender.clone(), recipient.clone(), delivery_contract::DeliveryStatus::Active, None); + // Setup mock delivery with driver assigned (required for reputation penalty on resolve) + let mut delivery_record = create_mock_delivery_record(&env, did(4), sender.clone(), recipient.clone(), delivery_contract::DeliveryStatus::Active, None); + delivery_record.driver = Some(driver.clone()); set_mock_delivery(&env, &delivery_id, did(4), &delivery_record); // Setup mock escrow as Paused (representing escrow paused after dispute raised) diff --git a/contracts/fleet_management_contract/lib.rs b/contracts/fleet_management_contract/lib.rs index 4dc13a4..40aed67 100644 --- a/contracts/fleet_management_contract/lib.rs +++ b/contracts/fleet_management_contract/lib.rs @@ -193,17 +193,21 @@ impl FleetManagementContract { /// Invite a driver to a fleet. Only the fleet owner may call this. /// - /// Stores a `Pending` invite for `driver` under this fleet. The driver - /// must later call `accept_fleet_invite` to become active. - pub fn add_driver_to_fleet(env: Env, fleet_id: FleetId, driver: Address) { + /// `caller` must be the registered fleet owner and must sign the + /// transaction. Stores a `Pending` invite for `driver` under this fleet. + /// The driver must later call `accept_fleet_invite` to become active. + pub fn add_driver_to_fleet(env: Env, caller: Address, fleet_id: FleetId, driver: Address) { + caller.require_auth(); + let profile: FleetProfile = env .storage() .persistent() .get(&DataKey::Fleet(fleet_id)) .unwrap_or_else(|| panic_with_error!(&env, FleetError::FleetNotFound)); - // Require fleet-owner authorisation. - profile.owner.require_auth(); + if profile.owner != caller { + panic_with_error!(&env, FleetError::Unauthorized); + } let invite_key = DataKey::DriverFleet(fleet_id, driver.clone()); diff --git a/contracts/fleet_management_contract/test.rs b/contracts/fleet_management_contract/test.rs index d3d32eb..0d4d57c 100644 --- a/contracts/fleet_management_contract/test.rs +++ b/contracts/fleet_management_contract/test.rs @@ -150,10 +150,10 @@ fn test_update_fleet_treasury_rejects_non_owner() { #[test] fn test_add_driver_stores_pending_invite() { let (env, client, _admin) = setup_test(); - let (fleet_id, _owner, _treasury) = register_fleet(&env, &client); + let (fleet_id, owner, _treasury) = register_fleet(&env, &client); let driver = Address::generate(&env); - client.add_driver_to_fleet(&fleet_id, &driver); + client.add_driver_to_fleet(&owner, &fleet_id, &driver); let status = client.get_driver_fleet_status(&fleet_id, &driver); assert_eq!(status, Some(DriverFleetStatus::Pending)); @@ -162,10 +162,10 @@ fn test_add_driver_stores_pending_invite() { #[test] fn test_add_driver_emits_driver_invited_event() { let (env, client, _admin) = setup_test(); - let (fleet_id, _owner, _treasury) = register_fleet(&env, &client); + let (fleet_id, owner, _treasury) = register_fleet(&env, &client); let driver = Address::generate(&env); - client.add_driver_to_fleet(&fleet_id, &driver); + client.add_driver_to_fleet(&owner, &fleet_id, &driver); let events = env.events().all(); let last_event = events.last().unwrap(); @@ -182,20 +182,49 @@ fn test_add_driver_emits_driver_invited_event() { #[should_panic(expected = "Error(Contract, #5)")] fn test_add_driver_twice_panics() { let (env, client, _admin) = setup_test(); - let (fleet_id, _owner, _treasury) = register_fleet(&env, &client); + let (fleet_id, owner, _treasury) = register_fleet(&env, &client); let driver = Address::generate(&env); - client.add_driver_to_fleet(&fleet_id, &driver); + client.add_driver_to_fleet(&owner, &fleet_id, &driver); // Second invite to the same driver must panic. - client.add_driver_to_fleet(&fleet_id, &driver); + client.add_driver_to_fleet(&owner, &fleet_id, &driver); } #[test] #[should_panic(expected = "Error(Contract, #4)")] fn test_add_driver_to_unknown_fleet_panics() { let (env, client, _admin) = setup_test(); + let caller = Address::generate(&env); + let driver = Address::generate(&env); + client.add_driver_to_fleet(&caller, &999, &driver); +} + +// Issue #74 — Fleet Owner Authorization ───────────────────────────────────── + +#[test] +#[should_panic(expected = "Error(Contract, #3)")] +fn test_add_driver_non_owner_is_rejected() { + let (env, client, _admin) = setup_test(); + let (fleet_id, _owner, _treasury) = register_fleet(&env, &client); + + let attacker = Address::generate(&env); + let driver = Address::generate(&env); + // attacker is not the fleet owner — must panic with Unauthorized. + client.add_driver_to_fleet(&attacker, &fleet_id, &driver); +} + +#[test] +fn test_add_driver_only_owner_can_invite() { + let (env, client, _admin) = setup_test(); + let (fleet_id, owner, _treasury) = register_fleet(&env, &client); + let driver = Address::generate(&env); - client.add_driver_to_fleet(&999, &driver); + // Fleet owner successfully invites a driver. + client.add_driver_to_fleet(&owner, &fleet_id, &driver); + assert_eq!( + client.get_driver_fleet_status(&fleet_id, &driver), + Some(DriverFleetStatus::Pending) + ); } // ── Issue #69 tests — accept_fleet_invite ──────────────────────────────────── @@ -203,10 +232,10 @@ fn test_add_driver_to_unknown_fleet_panics() { #[test] fn test_accept_invite_promotes_driver_to_active() { let (env, client, _admin) = setup_test(); - let (fleet_id, _owner, _treasury) = register_fleet(&env, &client); + let (fleet_id, owner, _treasury) = register_fleet(&env, &client); let driver = Address::generate(&env); - client.add_driver_to_fleet(&fleet_id, &driver); + client.add_driver_to_fleet(&owner, &fleet_id, &driver); client.accept_fleet_invite(&fleet_id, &driver); let status = client.get_driver_fleet_status(&fleet_id, &driver); @@ -216,13 +245,13 @@ fn test_accept_invite_promotes_driver_to_active() { #[test] fn test_accept_invite_increments_active_driver_count() { let (env, client, _admin) = setup_test(); - let (fleet_id, _owner, _treasury) = register_fleet(&env, &client); + let (fleet_id, owner, _treasury) = register_fleet(&env, &client); let driver_a = Address::generate(&env); let driver_b = Address::generate(&env); - client.add_driver_to_fleet(&fleet_id, &driver_a); - client.add_driver_to_fleet(&fleet_id, &driver_b); + client.add_driver_to_fleet(&owner, &fleet_id, &driver_a); + client.add_driver_to_fleet(&owner, &fleet_id, &driver_b); client.accept_fleet_invite(&fleet_id, &driver_a); let profile = client.get_fleet(&fleet_id); @@ -236,10 +265,10 @@ fn test_accept_invite_increments_active_driver_count() { #[test] fn test_accept_invite_emits_event() { let (env, client, _admin) = setup_test(); - let (fleet_id, _owner, _treasury) = register_fleet(&env, &client); + let (fleet_id, owner, _treasury) = register_fleet(&env, &client); let driver = Address::generate(&env); - client.add_driver_to_fleet(&fleet_id, &driver); + client.add_driver_to_fleet(&owner, &fleet_id, &driver); client.accept_fleet_invite(&fleet_id, &driver); let events = env.events().all(); @@ -264,10 +293,10 @@ fn test_accept_invite_without_prior_invite_panics() { #[should_panic(expected = "Error(Contract, #7)")] fn test_accept_invite_twice_panics() { let (env, client, _admin) = setup_test(); - let (fleet_id, _owner, _treasury) = register_fleet(&env, &client); + let (fleet_id, owner, _treasury) = register_fleet(&env, &client); let driver = Address::generate(&env); - client.add_driver_to_fleet(&fleet_id, &driver); + client.add_driver_to_fleet(&owner, &fleet_id, &driver); client.accept_fleet_invite(&fleet_id, &driver); // Accepting again must panic. client.accept_fleet_invite(&fleet_id, &driver); @@ -281,7 +310,7 @@ fn test_remove_active_driver_decrements_count() { let (fleet_id, owner, _treasury) = register_fleet(&env, &client); let driver = Address::generate(&env); - client.add_driver_to_fleet(&fleet_id, &driver); + client.add_driver_to_fleet(&owner, &fleet_id, &driver); client.accept_fleet_invite(&fleet_id, &driver); // Owner removes the driver. @@ -300,7 +329,7 @@ fn test_remove_pending_driver_does_not_affect_active_count() { let (fleet_id, owner, _treasury) = register_fleet(&env, &client); let driver = Address::generate(&env); - client.add_driver_to_fleet(&fleet_id, &driver); + client.add_driver_to_fleet(&owner, &fleet_id, &driver); // Driver has NOT accepted — still Pending. client.remove_driver_from_fleet(&fleet_id, &owner, &driver); @@ -315,10 +344,10 @@ fn test_remove_pending_driver_does_not_affect_active_count() { #[test] fn test_driver_can_remove_themselves() { let (env, client, _admin) = setup_test(); - let (fleet_id, _owner, _treasury) = register_fleet(&env, &client); + let (fleet_id, owner, _treasury) = register_fleet(&env, &client); let driver = Address::generate(&env); - client.add_driver_to_fleet(&fleet_id, &driver); + client.add_driver_to_fleet(&owner, &fleet_id, &driver); client.accept_fleet_invite(&fleet_id, &driver); // Driver removes themselves (caller == driver). @@ -334,7 +363,7 @@ fn test_remove_driver_emits_event() { let (fleet_id, owner, _treasury) = register_fleet(&env, &client); let driver = Address::generate(&env); - client.add_driver_to_fleet(&fleet_id, &driver); + client.add_driver_to_fleet(&owner, &fleet_id, &driver); client.remove_driver_from_fleet(&fleet_id, &owner, &driver); let events = env.events().all(); @@ -368,10 +397,10 @@ fn test_remove_driver_not_in_fleet_panics() { #[should_panic(expected = "Error(Contract, #3)")] fn test_remove_driver_unauthorized_caller_panics() { let (env, client, _admin) = setup_test(); - let (fleet_id, _owner, _treasury) = register_fleet(&env, &client); + let (fleet_id, owner, _treasury) = register_fleet(&env, &client); let driver = Address::generate(&env); - client.add_driver_to_fleet(&fleet_id, &driver); + client.add_driver_to_fleet(&owner, &fleet_id, &driver); let random_caller = Address::generate(&env); // random_caller is neither owner nor driver — must panic. @@ -388,7 +417,7 @@ fn test_roster_full_lifecycle_add_accept_remove() { let driver = Address::generate(&env); // Add: driver starts as Pending. - client.add_driver_to_fleet(&fleet_id, &driver); + client.add_driver_to_fleet(&owner, &fleet_id, &driver); assert_eq!( client.get_driver_fleet_status(&fleet_id, &driver), Some(DriverFleetStatus::Pending) @@ -417,9 +446,9 @@ fn test_roster_multiple_drivers_independent_states() { let driver_b = Address::generate(&env); let driver_c = Address::generate(&env); - client.add_driver_to_fleet(&fleet_id, &driver_a); - client.add_driver_to_fleet(&fleet_id, &driver_b); - client.add_driver_to_fleet(&fleet_id, &driver_c); + client.add_driver_to_fleet(&owner, &fleet_id, &driver_a); + client.add_driver_to_fleet(&owner, &fleet_id, &driver_b); + client.add_driver_to_fleet(&owner, &fleet_id, &driver_c); // Accept only a and b. client.accept_fleet_invite(&fleet_id, &driver_a); @@ -447,10 +476,10 @@ fn test_roster_multiple_drivers_independent_states() { #[test] fn test_roster_driver_can_leave_voluntarily() { let (env, client, _admin) = setup_test(); - let (fleet_id, _owner, _treasury) = register_fleet(&env, &client); + let (fleet_id, owner, _treasury) = register_fleet(&env, &client); let driver = Address::generate(&env); - client.add_driver_to_fleet(&fleet_id, &driver); + client.add_driver_to_fleet(&owner, &fleet_id, &driver); client.accept_fleet_invite(&fleet_id, &driver); // Driver removes themselves. @@ -466,12 +495,12 @@ fn test_roster_re_invite_after_removal() { let (fleet_id, owner, _treasury) = register_fleet(&env, &client); let driver = Address::generate(&env); - client.add_driver_to_fleet(&fleet_id, &driver); + client.add_driver_to_fleet(&owner, &fleet_id, &driver); client.accept_fleet_invite(&fleet_id, &driver); client.remove_driver_from_fleet(&fleet_id, &owner, &driver); // Should be possible to invite the same driver again after removal. - client.add_driver_to_fleet(&fleet_id, &driver); + client.add_driver_to_fleet(&owner, &fleet_id, &driver); assert_eq!( client.get_driver_fleet_status(&fleet_id, &driver), Some(DriverFleetStatus::Pending) @@ -483,10 +512,10 @@ fn test_roster_re_invite_after_removal() { #[test] fn test_get_payout_address_returns_treasury_for_active_driver() { let (env, client, _admin) = setup_test(); - let (fleet_id, _owner, treasury) = register_fleet(&env, &client); + let (fleet_id, owner, treasury) = register_fleet(&env, &client); let driver = Address::generate(&env); - client.add_driver_to_fleet(&fleet_id, &driver); + client.add_driver_to_fleet(&owner, &fleet_id, &driver); client.accept_fleet_invite(&fleet_id, &driver); let payout = client.get_payout_address(&driver, &fleet_id); @@ -507,10 +536,10 @@ fn test_get_payout_address_returns_driver_when_not_in_fleet() { #[test] fn test_get_payout_address_returns_driver_for_pending_invite() { let (env, client, _admin) = setup_test(); - let (fleet_id, _owner, _treasury) = register_fleet(&env, &client); + let (fleet_id, owner, _treasury) = register_fleet(&env, &client); let driver = Address::generate(&env); - client.add_driver_to_fleet(&fleet_id, &driver); + client.add_driver_to_fleet(&owner, &fleet_id, &driver); // Invite is Pending — not yet accepted. let payout = client.get_payout_address(&driver, &fleet_id); @@ -523,7 +552,7 @@ fn test_get_payout_address_returns_driver_after_removal() { let (fleet_id, owner, _treasury) = register_fleet(&env, &client); let driver = Address::generate(&env); - client.add_driver_to_fleet(&fleet_id, &driver); + client.add_driver_to_fleet(&owner, &fleet_id, &driver); client.accept_fleet_invite(&fleet_id, &driver); client.remove_driver_from_fleet(&fleet_id, &owner, &driver); @@ -538,7 +567,7 @@ fn test_get_payout_address_treasury_updates_are_reflected() { let (fleet_id, owner, _old_treasury) = register_fleet(&env, &client); let driver = Address::generate(&env); - client.add_driver_to_fleet(&fleet_id, &driver); + client.add_driver_to_fleet(&owner, &fleet_id, &driver); client.accept_fleet_invite(&fleet_id, &driver); let new_treasury = Address::generate(&env); @@ -551,15 +580,15 @@ fn test_get_payout_address_treasury_updates_are_reflected() { #[test] fn test_get_payout_address_multiple_drivers_same_fleet() { let (env, client, _admin) = setup_test(); - let (fleet_id, _owner, treasury) = register_fleet(&env, &client); + let (fleet_id, owner, treasury) = register_fleet(&env, &client); let driver_a = Address::generate(&env); let driver_b = Address::generate(&env); let driver_c = Address::generate(&env); - client.add_driver_to_fleet(&fleet_id, &driver_a); - client.add_driver_to_fleet(&fleet_id, &driver_b); - client.add_driver_to_fleet(&fleet_id, &driver_c); + client.add_driver_to_fleet(&owner, &fleet_id, &driver_a); + client.add_driver_to_fleet(&owner, &fleet_id, &driver_b); + client.add_driver_to_fleet(&owner, &fleet_id, &driver_c); // Only a and b accept; c stays pending. client.accept_fleet_invite(&fleet_id, &driver_a); diff --git a/contracts/settlement_contract/Cargo.toml b/contracts/settlement_contract/Cargo.toml index 887daf4..cf19d5c 100644 --- a/contracts/settlement_contract/Cargo.toml +++ b/contracts/settlement_contract/Cargo.toml @@ -9,3 +9,6 @@ path = "lib.rs" [dependencies] soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/settlement_contract/lib.rs b/contracts/settlement_contract/lib.rs index b94c213..f5cad4d 100644 --- a/contracts/settlement_contract/lib.rs +++ b/contracts/settlement_contract/lib.rs @@ -25,6 +25,7 @@ enum DataKey { Kes, AssetPair(Address, Address), DriverPreference(Address), + PlatformFeeBps, } #[contracterror] @@ -188,6 +189,25 @@ impl SettlementContract { .unwrap_or_else(|| panic_with_error!(&env, SettlementError::NotInitialized)) } + /// Set the platform cross-border conversion fee in basis points (1 bps = 0.01%). + /// Only the admin may call this. Maximum value 1000 bps (10%). + pub fn set_platform_fee_bps(env: Env, admin: Address, fee_bps: u32) { + admin.require_auth(); + require_admin(&env, &admin); + if fee_bps > 1000 { + panic_with_error!(&env, SettlementError::InvalidAmount); + } + env.storage().instance().set(&DataKey::PlatformFeeBps, &fee_bps); + } + + /// Return the current platform fee in basis points (defaults to 0). + pub fn get_platform_fee_bps(env: Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::PlatformFeeBps) + .unwrap_or(0) + } + pub fn calculate_swap_estimate( env: Env, source_asset: Address, @@ -227,12 +247,29 @@ impl SettlementContract { panic_with_error!(&env, SettlementError::InvalidAmount); } + let fee_bps: u32 = env + .storage() + .instance() + .get(&DataKey::PlatformFeeBps) + .unwrap_or(0); + + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .unwrap_or_else(|| panic_with_error!(&env, SettlementError::NotInitialized)); + if source_asset == destination_asset { if amount_in < min_amount_out { panic_with_error!(&env, SettlementError::SlippageExceeded); } - token::Client::new(&env, &source_asset).transfer(&caller, &recipient, &amount_in); - return amount_in; + let fee: i128 = (amount_in * fee_bps as i128) / 10000; + let net = amount_in - fee; + token::Client::new(&env, &source_asset).transfer(&caller, &recipient, &net); + if fee > 0 { + token::Client::new(&env, &source_asset).transfer(&caller, &admin, &fee); + } + return net; } require_supported_pair(&env, &source_asset, &destination_asset); @@ -257,14 +294,14 @@ impl SettlementContract { &(env.ledger().sequence() + 100), ); - let path = pair_path(&env, source_asset, destination_asset); + let path = pair_path(&env, source_asset.clone(), destination_asset.clone()); let amount_out: i128 = env.invoke_contract( &router, &Symbol::new(&env, "swap_exact_tokens_for_tokens"), soroban_sdk::vec![ &env, contract_address.into_val(&env), - recipient.clone().into_val(&env), + contract_address.into_val(&env), path.into_val(&env), amount_in.into_val(&env), min_amount_out.into_val(&env), @@ -275,12 +312,19 @@ impl SettlementContract { panic_with_error!(&env, SettlementError::SlippageExceeded); } + let fee: i128 = (amount_out * fee_bps as i128) / 10000; + let net = amount_out - fee; + token::Client::new(&env, &destination_asset).transfer(&contract_address, &recipient, &net); + if fee > 0 { + token::Client::new(&env, &destination_asset).transfer(&contract_address, &admin, &fee); + } + env.events().publish( (Symbol::new(&env, "settlement_swap_executed"),), - (caller, recipient, amount_in, amount_out), + (caller, recipient, amount_in, net), ); - amount_out + net } } @@ -288,7 +332,47 @@ impl SettlementContract { #[cfg(test)] mod test { use super::*; - use soroban_sdk::testutils::Address as _; + use soroban_sdk::{ + contract as soroban_contract, contractimpl as soroban_contractimpl, + testutils::Address as _, + token::{Client as TokenClient, StellarAssetClient}, + }; + + // ── Mock router ─────────────────────────────────────────────────────────── + + #[soroban_contract] + pub struct MockRouter; + + #[soroban_contractimpl] + impl MockRouter { + /// Store the rate in basis points (10000 = 1:1, 9500 = 0.95x). + pub fn set_rate(env: Env, rate_bps: i128) { + env.storage().instance().set(&0u32, &rate_bps); + } + + pub fn quote_exact_input(env: Env, _path: Vec
, amount_in: i128) -> i128 { + let rate: i128 = env.storage().instance().get(&0u32).unwrap_or(10000); + amount_in * rate / 10000 + } + + pub fn swap_exact_tokens_for_tokens( + env: Env, + _caller: Address, + recipient: Address, + path: Vec
, + amount_in: i128, + _min_amount_out: i128, + ) -> i128 { + let dest_asset = path.get(1).unwrap(); + let rate: i128 = env.storage().instance().get(&0u32).unwrap_or(10000); + let amount_out = amount_in * rate / 10000; + TokenClient::new(&env, &dest_asset) + .transfer(&env.current_contract_address(), &recipient, &amount_out); + amount_out + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── fn setup() -> (Env, SettlementContractClient<'static>, Address) { let env = Env::default(); @@ -300,27 +384,43 @@ mod test { (env, client, admin) } + fn setup_with_router() -> ( + Env, + SettlementContractClient<'static>, + Address, // admin + Address, // source_asset + Address, // dest_asset + Address, // router + ) { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(SettlementContract, ()); + let client = SettlementContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.init(&admin); + + let source_asset = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let dest_asset = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let router_id = env.register(MockRouter, ()); + + client.set_router(&admin, &router_id); + client.set_supported_asset_pair(&admin, &source_asset, &dest_asset, &true); + + (env, client, admin, source_asset, dest_asset, router_id) + } + + // ── Basic tests ─────────────────────────────────────────────────────────── + #[test] fn init_sets_admin_and_defaults() { - let (env, client, admin) = setup(); + let (_env, client, admin) = setup(); assert_eq!(client.get_admin(), admin); assert!(!client.is_supported(&SupportedAsset::Xlm)); assert!(!client.is_supported(&SupportedAsset::Usdc)); assert!(!client.is_supported(&SupportedAsset::Ngnc)); assert!(!client.is_supported(&SupportedAsset::Kes)); - - let xlm_key = env.as_contract(&client.address, || asset_key(SupportedAsset::Xlm)); - let usdc_key = env.as_contract(&client.address, || asset_key(SupportedAsset::Usdc)); - let ngnc_key = env.as_contract(&client.address, || asset_key(SupportedAsset::Ngnc)); - let kes_key = env.as_contract(&client.address, || asset_key(SupportedAsset::Kes)); - - assert_ne!(xlm_key, usdc_key); - assert_ne!(xlm_key, ngnc_key); - assert_ne!(xlm_key, kes_key); - assert_ne!(usdc_key, ngnc_key); - assert_ne!(usdc_key, kes_key); - assert_ne!(ngnc_key, kes_key); } #[test] @@ -335,4 +435,196 @@ mod test { assert!(!client.is_supported(&SupportedAsset::Ngnc)); assert!(!client.is_supported(&SupportedAsset::Kes)); } + + // ── Issue #92: Slippage protection tests ───────────────────────────────── + + #[test] + fn same_asset_swap_succeeds_within_slippage() { + let (env, client, admin) = setup(); + let token = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let caller = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token).mint(&caller, &1000); + + let out = client.execute_settlement_swap( + &caller, + &token, + &token, + &recipient, + &1000, + &1000, // min_amount_out == amount_in: exact match + ); + assert_eq!(out, 1000); + assert_eq!(TokenClient::new(&env, &token).balance(&recipient), 1000); + } + + #[test] + #[should_panic(expected = "Error(Contract, #6)")] + fn same_asset_swap_rejects_excessive_slippage() { + let (env, client, admin) = setup(); + let token = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let caller = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token).mint(&caller, &900); + + // min_amount_out (1000) > amount_in (900) → SlippageExceeded + client.execute_settlement_swap( + &caller, + &token, + &token, + &recipient, + &900, + &1000, + ); + } + + #[test] + #[should_panic(expected = "Error(Contract, #6)")] + fn cross_asset_swap_rejects_excessive_slippage() { + let (env, client, admin, source_asset, dest_asset, router_id) = setup_with_router(); + let caller = Address::generate(&env); + let recipient = Address::generate(&env); + + // Router will return 0.5x (5000 bps), but min_amount_out is 600 + MockRouterClient::new(&env, &router_id).set_rate(&5000); + StellarAssetClient::new(&env, &source_asset).mint(&caller, &1000); + + // estimated_out = 500, min = 600 → SlippageExceeded + client.execute_settlement_swap( + &caller, + &source_asset, + &dest_asset, + &recipient, + &1000, + &600, + ); + } + + #[test] + fn cross_asset_swap_succeeds_within_slippage() { + let (env, client, admin, source_asset, dest_asset, router_id) = setup_with_router(); + let caller = Address::generate(&env); + let recipient = Address::generate(&env); + + // Router returns 0.95x + MockRouterClient::new(&env, &router_id).set_rate(&9500); + StellarAssetClient::new(&env, &source_asset).mint(&caller, &1000); + // Mint dest tokens to mock router so it can complete the swap + StellarAssetClient::new(&env, &dest_asset).mint(&router_id, &1000); + + let out = client.execute_settlement_swap( + &caller, + &source_asset, + &dest_asset, + &recipient, + &1000, + &900, // accept up to 5% slippage + ); + + assert_eq!(out, 950); + assert_eq!(TokenClient::new(&env, &dest_asset).balance(&recipient), 950); + } + + // ── Issue #93: Platform FX fee tests ───────────────────────────────────── + + #[test] + fn platform_fee_bps_defaults_to_zero() { + let (_env, client, _admin) = setup(); + assert_eq!(client.get_platform_fee_bps(), 0); + } + + #[test] + fn admin_can_set_platform_fee_bps() { + let (_env, client, admin) = setup(); + client.set_platform_fee_bps(&admin, &50); // 0.5% + assert_eq!(client.get_platform_fee_bps(), 50); + } + + #[test] + #[should_panic(expected = "Error(Contract, #3)")] + fn non_admin_cannot_set_platform_fee_bps() { + let (env, client, _admin) = setup(); + let attacker = Address::generate(&env); + client.set_platform_fee_bps(&attacker, &50); + } + + #[test] + #[should_panic(expected = "Error(Contract, #5)")] + fn fee_bps_above_1000_is_rejected() { + let (_env, client, admin) = setup(); + client.set_platform_fee_bps(&admin, &1001); + } + + #[test] + fn same_asset_swap_deducts_platform_fee() { + let (env, client, admin) = setup(); + let token = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let caller = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token).mint(&caller, &1000); + client.set_platform_fee_bps(&admin, &100); // 1% fee + + let out = client.execute_settlement_swap( + &caller, + &token, + &token, + &recipient, + &1000, + &0, + ); + + // 1% fee on 1000 = 10; recipient gets 990 + assert_eq!(out, 990); + assert_eq!(TokenClient::new(&env, &token).balance(&recipient), 990); + assert_eq!(TokenClient::new(&env, &token).balance(&admin), 10); + assert_eq!(TokenClient::new(&env, &token).balance(&caller), 0); + } + + #[test] + fn cross_asset_swap_deducts_platform_fee() { + let (env, client, admin, source_asset, dest_asset, router_id) = setup_with_router(); + let caller = Address::generate(&env); + let recipient = Address::generate(&env); + + MockRouterClient::new(&env, &router_id).set_rate(&10000); // 1:1 rate + StellarAssetClient::new(&env, &source_asset).mint(&caller, &1000); + StellarAssetClient::new(&env, &dest_asset).mint(&router_id, &1000); + client.set_platform_fee_bps(&admin, &200); // 2% fee + + let out = client.execute_settlement_swap( + &caller, + &source_asset, + &dest_asset, + &recipient, + &1000, + &0, + ); + + // 1:1 swap → 1000 out, 2% fee = 20; recipient gets 980 + assert_eq!(out, 980); + assert_eq!(TokenClient::new(&env, &dest_asset).balance(&recipient), 980); + assert_eq!(TokenClient::new(&env, &dest_asset).balance(&admin), 20); + } + + #[test] + fn zero_fee_transfers_full_amount() { + let (env, client, admin) = setup(); + let token = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let caller = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token).mint(&caller, &500); + // fee_bps = 0 (default) + + let out = client.execute_settlement_swap( + &caller, &token, &token, &recipient, &500, &0, + ); + + assert_eq!(out, 500); + assert_eq!(TokenClient::new(&env, &token).balance(&recipient), 500); + assert_eq!(TokenClient::new(&env, &token).balance(&admin), 0); + } }