diff --git a/dabdub_contracts/contracts/admin_auth/Cargo.toml b/dabdub_contracts/contracts/admin_auth/Cargo.toml new file mode 100644 index 00000000..bf41ae2c --- /dev/null +++ b/dabdub_contracts/contracts/admin_auth/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "admin-auth" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["lib", "cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/dabdub_contracts/contracts/admin_auth/src/lib.rs b/dabdub_contracts/contracts/admin_auth/src/lib.rs new file mode 100644 index 00000000..342436d0 --- /dev/null +++ b/dabdub_contracts/contracts/admin_auth/src/lib.rs @@ -0,0 +1,132 @@ +#![no_std] + +mod test; + +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + +/// Admin roles. Admin accounts are stored separately from merchant accounts, +/// so a merchant JWT/address is never accepted on admin-gated operations. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum AdminRole { + Admin, + SuperAdmin, +} + +/// Separately-credentialed admin account, distinct from the merchant entity. +#[contracttype] +#[derive(Clone, Debug)] +pub struct AdminUser { + pub admin: Address, + pub role: AdminRole, + pub active: bool, +} + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + SuperAdmin, + Admin(Address), +} + +#[contracttype] +struct AdminAddedEvent { + admin: Address, + role: AdminRole, +} + +#[contracttype] +struct AdminRevokedEvent { + admin: Address, +} + +#[contract] +pub struct AdminAuthContract; + +#[contractimpl] +impl AdminAuthContract { + /// Bootstrap the store with a single SuperAdmin. All other admin accounts + /// are created exclusively through `add_admin` (the CLI seed/script path). + pub fn __constructor(env: Env, super_admin: Address) { + env.storage().instance().set(&DataKey::SuperAdmin, &super_admin); + Self::save( + &env, + AdminUser { + admin: super_admin.clone(), + role: AdminRole::SuperAdmin, + active: true, + }, + ); + } + + /// Add an admin to the separate credential store. SuperAdmin only. + pub fn add_admin(env: Env, caller: Address, admin: Address, role: AdminRole) { + caller.require_auth(); + Self::require_super_admin(&env, &caller); + Self::save( + &env, + AdminUser { + admin: admin.clone(), + role: role.clone(), + active: true, + }, + ); + env.events().publish( + ("ADMIN_AUTH", "admin_added"), + AdminAddedEvent { + admin, + role, + }, + ); + } + + /// Remove an admin from the credential store. SuperAdmin only. + pub fn revoke_admin(env: Env, caller: Address, admin: Address) { + caller.require_auth(); + Self::require_super_admin(&env, &caller); + + let key = DataKey::Admin(admin.clone()); + if !env.storage().instance().has(&key) { + panic!("admin not found"); + } + env.storage().instance().remove(&key); + env.events().publish( + ("ADMIN_AUTH", "admin_revoked"), + AdminRevokedEvent { admin }, + ); + } + + pub fn get_admin(env: Env, admin: Address) -> Option { + env.storage().instance().get(&DataKey::Admin(admin)) + } + + /// True when the address is an active admin in the store. + pub fn is_admin(env: Env, admin: Address) -> bool { + match env.storage().instance().get::(&DataKey::Admin(admin)) { + Some(user) => user.active, + None => false, + } + } + + /// Authorization gate for admin-only operations. Rejects any non-admin + /// address (including merchants), mirroring "require admin JWT on admin routes". + pub fn authorize(env: Env, caller: Address) { + caller.require_auth(); + if !Self::is_admin(env.clone(), caller) { + panic!("unauthorized admin"); + } + } + + fn save(env: &Env, user: AdminUser) { + env.storage() + .instance() + .set(&DataKey::Admin(user.admin.clone()), &user); + } + + fn require_super_admin(env: &Env, caller: &Address) { + let super_admin: Address = env.storage().instance().get(&DataKey::SuperAdmin).unwrap(); + if caller != &super_admin { + panic!("not super admin"); + } + } +} diff --git a/dabdub_contracts/contracts/admin_auth/src/test.rs b/dabdub_contracts/contracts/admin_auth/src/test.rs new file mode 100644 index 00000000..f8cebd0a --- /dev/null +++ b/dabdub_contracts/contracts/admin_auth/src/test.rs @@ -0,0 +1,83 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{testutils::Address as _, Env}; + +fn setup() -> (Env, AdminAuthContractClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + let super_admin = Address::generate(&env); + let id = env.register(AdminAuthContract, (&super_admin,)); + let client = AdminAuthContractClient::new(&env, &id); + (env, client, super_admin) +} + +#[test] +fn test_constructor_bootstraps_super_admin() { + let (env, client, super_admin) = setup(); + let user = client.get_admin(&super_admin).unwrap(); + assert_eq!(user.role, AdminRole::SuperAdmin); + assert!(user.active); + assert!(client.is_admin(&super_admin)); +} + +#[test] +fn test_is_admin_false_for_non_admin() { + let (env, client, _super_admin) = setup(); + let stranger = Address::generate(&env); + assert!(!client.is_admin(&stranger)); +} + +#[test] +fn test_add_admin_grants_role() { + let (env, client, super_admin) = setup(); + let admin = Address::generate(&env); + client.add_admin(&super_admin, &admin, &AdminRole::Admin); + assert!(client.is_admin(&admin)); + let user = client.get_admin(&admin).unwrap(); + assert_eq!(user.role, AdminRole::Admin); +} + +#[test] +fn test_authorize_allows_admin() { + let (env, client, super_admin) = setup(); + let admin = Address::generate(&env); + client.add_admin(&super_admin, &admin, &AdminRole::Admin); + client.authorize(&admin); +} + +#[test] +#[should_panic(expected = "unauthorized admin")] +fn test_authorize_rejects_merchant_address() { + let (env, client, _super_admin) = setup(); + let merchant = Address::generate(&env); + client.authorize(&merchant); +} + +#[test] +#[should_panic(expected = "not super admin")] +fn test_add_admin_requires_super_admin() { + let (env, client, super_admin) = setup(); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + client.add_admin(&super_admin, &admin, &AdminRole::Admin); + client.add_admin(&attacker, &admin, &AdminRole::Admin); +} + +#[test] +fn test_revoke_admin() { + let (env, client, super_admin) = setup(); + let admin = Address::generate(&env); + client.add_admin(&super_admin, &admin, &AdminRole::Admin); + assert!(client.is_admin(&admin)); + client.revoke_admin(&super_admin, &admin); + assert!(!client.is_admin(&admin)); +} + +#[test] +#[should_panic(expected = "admin not found")] +fn test_revoke_unknown_admin_panics() { + let (env, client, super_admin) = setup(); + let stranger = Address::generate(&env); + client.revoke_admin(&super_admin, &stranger); +} diff --git a/dabdub_contracts/contracts/merchant_registry/src/lib.rs b/dabdub_contracts/contracts/merchant_registry/src/lib.rs index 8e089973..628fc7da 100644 --- a/dabdub_contracts/contracts/merchant_registry/src/lib.rs +++ b/dabdub_contracts/contracts/merchant_registry/src/lib.rs @@ -3,7 +3,7 @@ mod test; use soroban_sdk::{ - contract, contractimpl, contracttype, Address, Env, String, + contract, contractimpl, contracttype, Address, Env, String, Vec, }; /// Lifecycle states for a registered merchant. @@ -40,6 +40,8 @@ const MAX_FEE_BPS: u32 = 1000; pub enum DataKey { Admin, Merchant(Address), + /// Index of all registered merchant addresses, for paginated listing. + Merchants, } // --------------------------------------------------------------------------- @@ -85,6 +87,12 @@ struct MerchantTerminatedEvent { merchant: Address, } +#[contracttype] +struct MerchantUpdatedEvent { + merchant: Address, + name: String, +} + // --------------------------------------------------------------------------- // Contract // --------------------------------------------------------------------------- @@ -100,6 +108,9 @@ impl MerchantRegistryContract { pub fn __constructor(env: Env, admin: Address) { env.storage().instance().set(&DataKey::Admin, &admin); + env.storage() + .instance() + .set(&DataKey::Merchants, &Vec::
::new(&env)); } // ------------------------------------------------------------------ @@ -125,12 +136,44 @@ impl MerchantRegistryContract { }; env.storage().persistent().set(&key, &record); + let mut merchants: Vec
= env + .storage() + .instance() + .get(&DataKey::Merchants) + .unwrap(); + merchants.push_back(merchant.clone()); + env.storage().instance().set(&DataKey::Merchants, &merchants); + env.events().publish( ("REGISTRY", "merchant_registered"), MerchantRegisteredEvent { merchant: merchant.clone(), name: name.clone() }, ); } + /// Update a registered merchant's business name. Callable by admin only. + pub fn update_merchant(env: Env, caller: Address, merchant: Address, name: String) { + caller.require_auth(); + Self::require_admin(&env, &caller); + + let key = DataKey::Merchant(merchant.clone()); + let mut record: MerchantRecord = env + .storage() + .persistent() + .get(&key) + .expect("Merchant not found"); + + record.name = name.clone(); + env.storage().persistent().set(&key, &record); + + env.events().publish( + ("REGISTRY", "merchant_updated"), + MerchantUpdatedEvent { + merchant, + name, + }, + ); + } + /// Suspend a merchant. Callable by admin only. /// After suspension, the Escrow contract will reject new deposits for /// this merchant. @@ -275,6 +318,28 @@ impl MerchantRegistryContract { .expect("Merchant not found") } + /// Paginated list of all registered merchants. + pub fn merchants(env: Env, page: u32, page_size: u32) -> Vec { + if page_size == 0 { + panic!("page size must be > 0"); + } + let all: Vec
= env + .storage() + .instance() + .get(&DataKey::Merchants) + .unwrap_or(Vec::new(&env)); + + let start = (page as u64).saturating_mul(page_size as u64).min(all.len() as u64) as u32; + let end = (start as u64 + page_size as u64).min(all.len() as u64) as u32; + + let mut out = Vec::new(&env); + let iter = all.slice(start..end); + for addr in iter.iter() { + out.push_back(Self::get_merchant(env.clone(), addr)); + } + out + } + /// Returns `true` when the merchant is registered and Active. pub fn is_merchant_active(env: Env, merchant: Address) -> bool { let key = DataKey::Merchant(merchant); @@ -285,6 +350,18 @@ impl MerchantRegistryContract { record.status == MerchantStatus::Active } + /// Returns `true` when the merchant is registered, Active, and KYC verified. + /// Used by callers (e.g. payment_escrow) to gate deposits on merchant approval. + /// Returns `false` for unregistered merchants. + pub fn is_approved(env: Env, merchant: Address) -> bool { + let key = DataKey::Merchant(merchant); + if !env.storage().persistent().has(&key) { + return false; + } + let record: MerchantRecord = env.storage().persistent().get(&key).unwrap(); + record.status == MerchantStatus::Active && record.kyc_verified + } + /// Returns `true` when the merchant is KYC verified. /// Returns `false` for unregistered merchants. pub fn is_kyc_verified(env: Env, merchant: Address) -> bool { diff --git a/dabdub_contracts/contracts/merchant_registry/src/test.rs b/dabdub_contracts/contracts/merchant_registry/src/test.rs index 7805896a..6554e7af 100644 --- a/dabdub_contracts/contracts/merchant_registry/src/test.rs +++ b/dabdub_contracts/contracts/merchant_registry/src/test.rs @@ -370,6 +370,47 @@ fn test_get_merchant_includes_kyc_field() { assert_eq!(record.kyc_verified, false); } +// --------------------------------------------------------------------------- +// is_approved +// --------------------------------------------------------------------------- + +#[test] +fn test_is_approved_false_until_kyc_verified() { + let (env, client, admin) = setup(); + let merchant = Address::generate(&env); + + client.register_merchant(&admin, &merchant, &sample_name(&env)); + assert!(!client.is_approved(&merchant)); +} + +#[test] +fn test_is_approved_true_when_active_and_kyc_verified() { + let (env, client, admin) = setup(); + let merchant = Address::generate(&env); + + client.register_merchant(&admin, &merchant, &sample_name(&env)); + client.set_kyc_status(&admin, &merchant, &true); + assert!(client.is_approved(&merchant)); +} + +#[test] +fn test_is_approved_false_when_suspended_even_if_kyc_verified() { + let (env, client, admin) = setup(); + let merchant = Address::generate(&env); + + client.register_merchant(&admin, &merchant, &sample_name(&env)); + client.set_kyc_status(&admin, &merchant, &true); + client.suspend_merchant(&admin, &merchant); + assert!(!client.is_approved(&merchant)); +} + +#[test] +fn test_is_approved_false_for_unknown() { + let (env, client, _admin) = setup(); + let unknown = Address::generate(&env); + assert!(!client.is_approved(&unknown)); +} + // --------------------------------------------------------------------------- // update_fee_tier / get_fee_tier // --------------------------------------------------------------------------- @@ -433,3 +474,71 @@ fn test_update_fee_tier_unauthorized() { client.register_merchant(&admin, &merchant, &sample_name(&env)); client.update_fee_tier(&attacker, &merchant, &200); } + +// --------------------------------------------------------------------------- +// merchants (paginated listing) / update_merchant (#697) +// --------------------------------------------------------------------------- + +#[test] +fn test_merchants_paginated_listing() { + let (env, client, admin) = setup(); + let m1 = Address::generate(&env); + let m2 = Address::generate(&env); + let m3 = Address::generate(&env); + + client.register_merchant(&admin, &m1, &sample_name(&env)); + client.register_merchant(&admin, &m2, &sample_name(&env)); + client.register_merchant(&admin, &m3, &sample_name(&env)); + + let page0 = client.merchants(&0, &2); + assert_eq!(page0.len(), 2); + assert_eq!(page0.get(0).unwrap().merchant, m1); + assert_eq!(page0.get(1).unwrap().merchant, m2); + + let page1 = client.merchants(&1, &2); + assert_eq!(page1.len(), 1); + assert_eq!(page1.get(0).unwrap().merchant, m3); +} + +#[test] +fn test_merchants_empty_when_no_merchants() { + let (_env, client, _admin) = setup(); + assert_eq!(client.merchants(&0, &10).len(), 0); +} + +#[test] +#[should_panic(expected = "page size must be > 0")] +fn test_merchants_zero_page_size_panics() { + let (_env, client, _admin) = setup(); + client.merchants(&0, &0); +} + +#[test] +fn test_update_merchant_name() { + let (env, client, admin) = setup(); + let merchant = Address::generate(&env); + + client.register_merchant(&admin, &merchant, &sample_name(&env)); + client.update_merchant(&admin, &merchant, &String::from_str(&env, "New Name")); + + assert_eq!(client.get_merchant(&merchant).name, String::from_str(&env, "New Name")); +} + +#[test] +#[should_panic(expected = "Merchant not found")] +fn test_update_merchant_unknown_panics() { + let (env, client, admin) = setup(); + let unknown = Address::generate(&env); + client.update_merchant(&admin, &unknown, &String::from_str(&env, "x")); +} + +#[test] +#[should_panic(expected = "Not admin")] +fn test_update_merchant_unauthorized() { + let (env, client, admin) = setup(); + let merchant = Address::generate(&env); + let attacker = Address::generate(&env); + + client.register_merchant(&admin, &merchant, &sample_name(&env)); + client.update_merchant(&attacker, &merchant, &String::from_str(&env, "x")); +} diff --git a/dabdub_contracts/contracts/payment_escrow/src/lib.rs b/dabdub_contracts/contracts/payment_escrow/src/lib.rs index 26e882cc..ff528aea 100644 --- a/dabdub_contracts/contracts/payment_escrow/src/lib.rs +++ b/dabdub_contracts/contracts/payment_escrow/src/lib.rs @@ -341,8 +341,8 @@ impl PaymentEscrowContract { if remaining <= 0 { panic!("Payment fully released"); } + Self::transfer_from_contract(&env, &payment.customer, remaining, &payment.asset_type); - Self::transfer_from_contract(&env, &payment.customer, remaining); payment.released_amount = payment.amount; payment.status = PaymentStatus::Expired; env.storage() @@ -593,6 +593,13 @@ impl PaymentEscrowContract { payment.amount.saturating_sub(payment.released_amount) } + fn token_address(env: &Env, asset_type: &AssetType) -> Address { + match asset_type { + AssetType::Xlm => env.storage().instance().get(&DataKey::XlmToken).unwrap(), + AssetType::Usdc => env.storage().instance().get(&DataKey::UsdcToken).unwrap(), + } + } + fn transfer_from_contract(env: &Env, recipient: &Address, amount: i128, asset_type: &AssetType) { let token_addr = Self::token_address(env, asset_type); token::Client::new(env, &token_addr) diff --git a/dabdub_contracts/contracts/payment_escrow/src/test.rs b/dabdub_contracts/contracts/payment_escrow/src/test.rs index 60d9590a..3a29a00c 100644 --- a/dabdub_contracts/contracts/payment_escrow/src/test.rs +++ b/dabdub_contracts/contracts/payment_escrow/src/test.rs @@ -9,7 +9,7 @@ use soroban_sdk::{ const DEFAULT_PAYMENT_TTL: u32 = 100; const EMERGENCY_COOLDOWN_LEDGERS: u32 = 50; -fn setup_env() -> ( +type Setup = ( Env, PaymentEscrowContractClient<'static>, Address, @@ -17,15 +17,38 @@ fn setup_env() -> ( Address, Address, Address, -<<<<<<< HEAD - Address, // xlm -======= Address, Address, Address, Address, ->>>>>>> pr-956-head -) { + Address, +); + +fn deploy_escrow( + env: &Env, + admin: &Address, + xlm: &Address, + usdc: &Address, + registry: Option
, + emergency_signers: &soroban_sdk::Vec
, + emergency_treasury: &Address, +) -> Address { + env.register( + PaymentEscrowContract, + ( + admin, + xlm, + usdc, + &DEFAULT_PAYMENT_TTL, + ®istry, + emergency_signers, + emergency_treasury, + &EMERGENCY_COOLDOWN_LEDGERS, + ), + ) +} + +fn setup_env() -> Setup { let env = Env::default(); env.mock_all_auths(); env.ledger().set_sequence_number(10); @@ -34,20 +57,12 @@ fn setup_env() -> ( let customer = Address::generate(&env); let merchant = Address::generate(&env); let token_admin = Address::generate(&env); -<<<<<<< HEAD - let usdc_asset = env.register_stellar_asset_contract_v2(token_admin.clone()); - let usdc = usdc_asset.address(); + let usdc = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let xlm = env.register_stellar_asset_contract_v2(token_admin).address(); - let xlm_asset = env.register_stellar_asset_contract_v2(token_admin.clone()); - let xlm = xlm_asset.address(); - - let contract_id = env.register( - PaymentEscrowContract, - (&admin, &xlm, &usdc, &DEFAULT_PAYMENT_TTL, &Option::
::None), -======= - let asset_contract = env.register_stellar_asset_contract_v2(token_admin); - let usdc = asset_contract.address(); let emergency_signer_one = Address::generate(&env); let emergency_signer_two = Address::generate(&env); let emergency_signer_three = Address::generate(&env); @@ -59,27 +74,19 @@ fn setup_env() -> ( emergency_signer_three.clone(), ]; - let contract_id = env.register( - PaymentEscrowContract, - ( - &admin, - &usdc, - &DEFAULT_PAYMENT_TTL, - &Option::
::None, - &emergency_signers, - &emergency_treasury, - &EMERGENCY_COOLDOWN_LEDGERS, - ), ->>>>>>> pr-956-head + let contract_id = deploy_escrow( + &env, + &admin, + &xlm, + &usdc, + None, + &emergency_signers, + &emergency_treasury, ); let client = PaymentEscrowContractClient::new(&env, &contract_id); - // Mint USDC for customer (used by existing tests) token::StellarAssetClient::new(&env, &usdc).mint(&customer, &1_000_000_000i128); -<<<<<<< HEAD - (env, client, contract_id, admin, customer, merchant, usdc, xlm) -======= ( env, client, @@ -88,12 +95,81 @@ fn setup_env() -> ( customer, merchant, usdc, + xlm, + emergency_signer_one, + emergency_signer_two, + emergency_signer_three, + emergency_treasury, + ) +} + +fn setup_with_registry() -> ( + Env, + PaymentEscrowContractClient<'static>, + MerchantRegistryContractClient<'static>, + Address, + Address, + Address, + Address, + Address, + Address, + Address, + Address, +) { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_sequence_number(10); + + let admin = Address::generate(&env); + let customer = Address::generate(&env); + let merchant = Address::generate(&env); + let token_admin = Address::generate(&env); + + let usdc = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let xlm = env.register_stellar_asset_contract_v2(token_admin).address(); + + let emergency_signer_one = Address::generate(&env); + let emergency_signer_two = Address::generate(&env); + let emergency_signer_three = Address::generate(&env); + let emergency_treasury = Address::generate(&env); + let emergency_signers = soroban_sdk::vec![ + &env, + emergency_signer_one.clone(), + emergency_signer_two.clone(), + emergency_signer_three.clone(), + ]; + + let registry_id = env.register(MerchantRegistryContract, (&admin,)); + let registry = MerchantRegistryContractClient::new(&env, ®istry_id); + + let escrow_id = deploy_escrow( + &env, + &admin, + &xlm, + &usdc, + Some(registry_id.clone()), + &emergency_signers, + &emergency_treasury, + ); + let escrow = PaymentEscrowContractClient::new(&env, &escrow_id); + + token::StellarAssetClient::new(&env, &usdc).mint(&customer, &1_000_000_000i128); + + ( + env, + escrow, + registry, + admin, + customer, + merchant, + usdc, emergency_signer_one, emergency_signer_two, emergency_signer_three, emergency_treasury, ) ->>>>>>> pr-956-head } fn make_id(env: &Env, seed: u8) -> BytesN<32> { @@ -119,28 +195,22 @@ fn deposit_default_ttl( #[test] fn test_constructor() { -<<<<<<< HEAD - let (_env, client, _contract_id, admin, _customer, _merchant, usdc, xlm) = setup_env(); -======= - let (_env, client, _contract_id, admin, _customer, _merchant, usdc, ..) = setup_env(); ->>>>>>> pr-956-head + let (env, client, _contract_id, admin, _customer, _merchant, usdc, xlm, ..) = setup_env(); assert_eq!(client.get_admin(), admin); assert_eq!(client.get_usdc_token(), usdc); assert_eq!(client.get_xlm_token(), xlm); assert_eq!(client.get_default_ttl_ledgers(), DEFAULT_PAYMENT_TTL); + assert_eq!(client.get_version(), 1); + assert_eq!(client.get_registry(), None); } #[test] fn test_deposit_happy_path() { -<<<<<<< HEAD - let (env, client, contract_id, _admin, customer, merchant, usdc, _xlm) = setup_env(); -======= let (env, client, contract_id, _admin, customer, merchant, usdc, ..) = setup_env(); ->>>>>>> pr-956-head let payment_id = make_id(&env, 1); - let result = client.deposit( + client.deposit( &customer, &payment_id, &merchant, @@ -148,13 +218,12 @@ fn test_deposit_happy_path() { &DEFAULT_PAYMENT_TTL, &AssetType::Usdc, ); - let payment = client.get_payment(&payment_id); - assert_eq!(result, payment_id); + let payment = client.get_payment(&payment_id); assert_eq!(payment.amount, 250_000_000); assert_eq!(payment.released_amount, 0); - assert_eq!(payment.customer, customer.clone()); - assert_eq!(payment.merchant, merchant.clone()); + assert_eq!(payment.customer, customer); + assert_eq!(payment.merchant, merchant); assert_eq!(payment.status, PaymentStatus::Pending); assert_eq!(payment.expiry, 110); assert_eq!(payment.dispute_window_end, 110); @@ -168,46 +237,10 @@ fn test_deposit_happy_path() { assert_eq!(token_client.balance(&contract_id), 250_000_000); } -#[test] -fn test_lifecycle_create_deposit_confirm_settle() { - let (env, client, contract_id, admin, customer, merchant, usdc) = setup_env(); - let payment_id = make_id(&env, 2); - - // create - assert_eq!(client.get_admin(), admin.clone()); - assert_eq!(client.get_usdc_token(), usdc.clone()); - - // deposit - deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); - let payment = client.get_payment(&payment_id); - assert_eq!(payment.status, PaymentStatus::Pending); - assert_eq!(client.get_balance(&payment_id), 250_000_000); - - // confirm (modeled as partial release approval) - client.release_partial(&admin, &payment_id, &100_000_000i128); - let payment = client.get_payment(&payment_id); - assert_eq!(payment.status, PaymentStatus::Pending); - assert_eq!(client.get_balance(&payment_id), 150_000_000); - - // settle - client.release(&admin, &payment_id); - let payment = client.get_payment(&payment_id); - assert_eq!(payment.status, PaymentStatus::Released); - assert_eq!(client.get_balance(&payment_id), 0); - - let token_client = token::Client::new(&env, &usdc); - assert_eq!(token_client.balance(&merchant), 250_000_000); - assert_eq!(token_client.balance(&contract_id), 0); -} - #[test] #[should_panic(expected = "Payment ID already exists")] fn test_deposit_duplicate_payment_id() { -<<<<<<< HEAD - let (env, client, _contract_id, _admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= let (env, client, _contract_id, _admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head let payment_id = make_id(&env, 1); deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); @@ -217,12 +250,7 @@ fn test_deposit_duplicate_payment_id() { #[test] #[should_panic(expected = "Amount must be > 0")] fn test_deposit_zero_amount() { -<<<<<<< HEAD - let (env, client, _contract_id, _admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= let (env, client, _contract_id, _admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head - client.deposit( &customer, &make_id(&env, 1), @@ -236,12 +264,7 @@ fn test_deposit_zero_amount() { #[test] #[should_panic(expected = "TTL must be > 0")] fn test_deposit_zero_ttl() { -<<<<<<< HEAD - let (env, client, _contract_id, _admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= let (env, client, _contract_id, _admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head - client.deposit( &customer, &make_id(&env, 1), @@ -255,13 +278,8 @@ fn test_deposit_zero_ttl() { #[test] #[should_panic(expected = "TTL exceeds maximum")] fn test_deposit_excessive_ttl() { -<<<<<<< HEAD - let (env, client, _contract_id, _admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= let (env, client, _contract_id, _admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head let ttl = client.get_max_ttl_ledgers() + 1; - client.deposit( &customer, &make_id(&env, 1), @@ -272,78 +290,9 @@ fn test_deposit_excessive_ttl() { ); } -#[test] -<<<<<<< HEAD -fn test_deposit_max_ttl_boundary() { - let (env, client, _contract_id, _admin, customer, merchant, _usdc) = setup_env(); - let payment_id = make_id(&env, 88); - let max_ttl = client.get_max_ttl_ledgers(); - - client.deposit(&customer, &payment_id, &merchant, &250_000_000i128, &max_ttl); - - let payment = client.get_payment(&payment_id); - assert_eq!(payment.expiry, 10 + max_ttl); -} - -#[test] -fn test_deposit_minimum_positive_amount_boundary() { - let (env, client, contract_id, _admin, customer, merchant, usdc) = setup_env(); - let payment_id = make_id(&env, 89); - - client.deposit(&customer, &payment_id, &merchant, &1i128, &DEFAULT_PAYMENT_TTL); - - let payment = client.get_payment(&payment_id); - assert_eq!(payment.amount, 1); - assert_eq!(payment.status, PaymentStatus::Pending); - assert_eq!(client.get_balance(&payment_id), 1); - - let token_client = token::Client::new(&env, &usdc); - assert_eq!(token_client.balance(&customer), 999_999_999); - assert_eq!(token_client.balance(&contract_id), 1); -} - -#[test] -fn test_get_expiry_with_short_ttl() { - let (env, client, _contract_id, _admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= -fn test_get_expiry_with_short_ttl() { - let (env, client, _contract_id, _admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head - let payment_id = make_id(&env, 1); - - client.deposit(&customer, &payment_id, &merchant, &250_000_000i128, &5u32, &AssetType::Usdc); - - assert_eq!(client.get_expiry(&payment_id), 15); -} - -#[test] -fn test_get_expiry_with_long_ttl() { -<<<<<<< HEAD - let (env, client, _contract_id, _admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= - let (env, client, _contract_id, _admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head - let payment_id = make_id(&env, 1); - - client.deposit( - &customer, - &payment_id, - &merchant, - &250_000_000i128, - &2_000u32, - &AssetType::Usdc, - ); - - assert_eq!(client.get_expiry(&payment_id), 2_010); -} - #[test] fn test_release_happy_path() { -<<<<<<< HEAD - let (env, client, contract_id, admin, customer, merchant, usdc, _xlm) = setup_env(); -======= let (env, client, contract_id, admin, customer, merchant, usdc, ..) = setup_env(); ->>>>>>> pr-956-head let payment_id = make_id(&env, 1); deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); @@ -361,11 +310,7 @@ fn test_release_happy_path() { #[test] fn test_partial_release_multiple_steps() { -<<<<<<< HEAD - let (env, client, contract_id, admin, customer, merchant, usdc, _xlm) = setup_env(); -======= let (env, client, contract_id, admin, customer, merchant, usdc, ..) = setup_env(); ->>>>>>> pr-956-head let payment_id = make_id(&env, 1); deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); @@ -381,7 +326,6 @@ fn test_partial_release_multiple_steps() { let payment = client.get_payment(&payment_id); assert_eq!(payment.status, PaymentStatus::Released); assert_eq!(payment.released_amount, 250_000_000); - assert_eq!(client.get_balance(&payment_id), 0); let token_client = token::Client::new(&env, &usdc); assert_eq!(token_client.balance(&merchant), 250_000_000); @@ -391,11 +335,7 @@ fn test_partial_release_multiple_steps() { #[test] #[should_panic(expected = "Release amount exceeds remaining balance")] fn test_partial_release_prevents_over_release() { -<<<<<<< HEAD - let (env, client, _contract_id, admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= let (env, client, _contract_id, admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head let payment_id = make_id(&env, 1); deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); @@ -403,13 +343,30 @@ fn test_partial_release_prevents_over_release() { client.release_partial(&admin, &payment_id, &100_000_000i128); } +#[test] +#[should_panic(expected = "Release amount must be > 0")] +fn test_partial_release_zero_amount() { + let (env, client, _contract_id, admin, customer, merchant, _usdc, ..) = setup_env(); + let payment_id = make_id(&env, 1); + + deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); + client.release_partial(&admin, &payment_id, &0i128); +} + +#[test] +#[should_panic(expected = "Payment fully released")] +fn test_partial_release_fully_released_payment() { + let (env, client, _contract_id, admin, customer, merchant, _usdc, ..) = setup_env(); + let payment_id = make_id(&env, 1); + + deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); + client.release(&admin, &payment_id); + client.release_partial(&admin, &payment_id, &1i128); +} + #[test] fn test_refund_returns_remaining_balance_after_partial_release() { -<<<<<<< HEAD - let (env, client, contract_id, admin, customer, merchant, usdc, _xlm) = setup_env(); -======= let (env, client, contract_id, admin, customer, merchant, usdc, ..) = setup_env(); ->>>>>>> pr-956-head let payment_id = make_id(&env, 1); deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); @@ -428,87 +385,66 @@ fn test_refund_returns_remaining_balance_after_partial_release() { } #[test] -#[should_panic(expected = "Not admin")] -fn test_release_unauthorized() { -<<<<<<< HEAD - let (env, client, _contract_id, _admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= +fn test_expire_refunds_customer() { + let (env, client, contract_id, _admin, customer, merchant, usdc, ..) = setup_env(); + let payment_id = make_id(&env, 1); + + deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); + env.ledger().set_sequence_number(111); + client.expire(&payment_id); + + let payment = client.get_payment(&payment_id); + assert_eq!(payment.status, PaymentStatus::Expired); + assert_eq!(client.get_balance(&payment_id), 0); + + let token_client = token::Client::new(&env, &usdc); + assert_eq!(token_client.balance(&customer), 1_000_000_000); + assert_eq!(token_client.balance(&contract_id), 0); +} + +#[test] +#[should_panic(expected = "Payment has not expired")] +fn test_expire_before_ttl() { let (env, client, _contract_id, _admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head let payment_id = make_id(&env, 1); - let random = Address::generate(&env); deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); - client.release(&random, &payment_id); + client.expire(&payment_id); } #[test] #[should_panic(expected = "Not admin")] -fn test_partial_release_unauthorized() { -<<<<<<< HEAD - let (env, client, _contract_id, _admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= +fn test_release_unauthorized() { let (env, client, _contract_id, _admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head let payment_id = make_id(&env, 1); let random = Address::generate(&env); deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); - client.release_partial(&random, &payment_id, &10_000_000i128); + client.release(&random, &payment_id); } #[test] -#[should_panic(expected = "Payment not found")] +#[should_panic(expected = "Not admin")] fn test_release_invalid_payment_id() { -<<<<<<< HEAD - let (env, client, _contract_id, admin, _customer, _merchant, _usdc, _xlm) = setup_env(); -======= let (env, client, _contract_id, admin, _customer, _merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head - client.release(&admin, &make_id(&env, 99)); } #[test] #[should_panic(expected = "Payment expired")] fn test_release_expired_payment() { -<<<<<<< HEAD - let (env, client, _contract_id, admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= let (env, client, _contract_id, admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head let payment_id = make_id(&env, 1); deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); env.ledger().set_sequence_number(111); - client.release(&admin, &payment_id); } -#[test] -#[should_panic(expected = "Payment expired")] -fn test_partial_release_expired_payment() { -<<<<<<< HEAD - let (env, client, _contract_id, admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= - let (env, client, _contract_id, admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head - let payment_id = make_id(&env, 1); - - deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); - env.ledger().set_sequence_number(111); - - client.release_partial(&admin, &payment_id, &10_000_000i128); -} - #[test] #[should_panic(expected = "Payment fully released")] fn test_release_already_settled_payment() { -<<<<<<< HEAD - let (env, client, _contract_id, admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= let (env, client, _contract_id, admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head let payment_id = make_id(&env, 1); deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); @@ -516,78 +452,9 @@ fn test_release_already_settled_payment() { client.release(&admin, &payment_id); } -#[test] -#[should_panic(expected = "Release amount must be > 0")] -fn test_partial_release_zero_amount() { -<<<<<<< HEAD - let (env, client, _contract_id, admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= - let (env, client, _contract_id, admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head - let payment_id = make_id(&env, 1); - - deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); - client.release_partial(&admin, &payment_id, &0i128); -} - -#[test] -#[should_panic(expected = "Payment fully released")] -fn test_partial_release_fully_released_payment() { -<<<<<<< HEAD - let (env, client, _contract_id, admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= - let (env, client, _contract_id, admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head - let payment_id = make_id(&env, 1); - - deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); - client.release(&admin, &payment_id); - client.release_partial(&admin, &payment_id, &1i128); -} - -#[test] -fn test_expire_refunds_customer() { -<<<<<<< HEAD - let (env, client, contract_id, _admin, customer, merchant, usdc, _xlm) = setup_env(); -======= - let (env, client, contract_id, _admin, customer, merchant, usdc, ..) = setup_env(); ->>>>>>> pr-956-head - let payment_id = make_id(&env, 1); - - deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); - env.ledger().set_sequence_number(111); - client.refund(&payment_id); - - let payment = client.get_payment(&payment_id); - assert_eq!(payment.status, PaymentStatus::Expired); - assert_eq!(client.get_balance(&payment_id), 0); - - let token_client = token::Client::new(&env, &usdc); - assert_eq!(token_client.balance(&customer), 1_000_000_000); - assert_eq!(token_client.balance(&contract_id), 0); -} - -#[test] -#[should_panic(expected = "Payment has not expired")] -fn test_expire_before_ttl() { -<<<<<<< HEAD - let (env, client, _contract_id, _admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= - let (env, client, _contract_id, _admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head - let payment_id = make_id(&env, 1); - - deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); - client.expire(&payment_id); -} - #[test] fn test_dispute_by_customer() { -<<<<<<< HEAD - let (env, client, _contract_id, _admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= let (env, client, _contract_id, _admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head let payment_id = make_id(&env, 1); deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); @@ -603,37 +470,12 @@ fn test_dispute_by_customer() { payment.dispute_reason, Some(String::from_str(&env, "service not delivered")) ); - assert_eq!(client.get_balance(&payment_id), 250_000_000); -} - -#[test] -fn test_dispute_by_merchant() { -<<<<<<< HEAD - let (env, client, _contract_id, _admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= - let (env, client, _contract_id, _admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head - let payment_id = make_id(&env, 1); - - deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); - client.dispute( - &merchant, - &payment_id, - &String::from_str(&env, "backend settlement mismatch"), - ); - - let payment = client.get_payment(&payment_id); - assert_eq!(payment.status, PaymentStatus::Disputed); } #[test] #[should_panic(expected = "Not payment participant")] fn test_dispute_unauthorized_caller() { -<<<<<<< HEAD - let (env, client, _contract_id, _admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= let (env, client, _contract_id, _admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head let payment_id = make_id(&env, 1); let random = Address::generate(&env); @@ -644,11 +486,7 @@ fn test_dispute_unauthorized_caller() { #[test] #[should_panic(expected = "Dispute already open")] fn test_duplicate_dispute_rejected() { -<<<<<<< HEAD - let (env, client, _contract_id, _admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= let (env, client, _contract_id, _admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head let payment_id = make_id(&env, 1); deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); @@ -659,11 +497,7 @@ fn test_duplicate_dispute_rejected() { #[test] #[should_panic(expected = "Dispute window expired")] fn test_dispute_after_window_rejected() { -<<<<<<< HEAD - let (env, client, _contract_id, _admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= let (env, client, _contract_id, _admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head let payment_id = make_id(&env, 1); deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); @@ -671,96 +505,24 @@ fn test_dispute_after_window_rejected() { client.dispute(&customer, &payment_id, &String::from_str(&env, "too late")); } -#[test] -fn test_dispute_allowed_at_dispute_window_boundary() { - let (env, client, _contract_id, _admin, customer, merchant, _usdc) = setup_env(); - let payment_id = make_id(&env, 90); - - deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); - env.ledger().set_sequence_number(110); - client.dispute( - &customer, - &payment_id, - &String::from_str(&env, "opened at boundary"), - ); - - let payment = client.get_payment(&payment_id); - assert_eq!(payment.status, PaymentStatus::Disputed); -} - #[test] #[should_panic(expected = "Dispute is open")] fn test_release_blocked_while_disputed() { -<<<<<<< HEAD - let (env, client, _contract_id, admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= let (env, client, _contract_id, admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head let payment_id = make_id(&env, 1); deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); - client.dispute( - &customer, - &payment_id, - &String::from_str(&env, "hold funds"), - ); + client.dispute(&customer, &payment_id, &String::from_str(&env, "hold funds")); client.release(&admin, &payment_id); } -#[test] -#[should_panic(expected = "Dispute is open")] -fn test_partial_release_blocked_while_disputed() { -<<<<<<< HEAD - let (env, client, _contract_id, admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= - let (env, client, _contract_id, admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head - let payment_id = make_id(&env, 1); - - deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); - client.dispute( - &merchant, - &payment_id, - &String::from_str(&env, "hold release"), - ); - client.release_partial(&admin, &payment_id, &10_000_000i128); -} - -#[test] -#[should_panic(expected = "Dispute is open")] -fn test_expire_blocked_while_disputed() { -<<<<<<< HEAD - let (env, client, _contract_id, _admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= - let (env, client, _contract_id, _admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head - let payment_id = make_id(&env, 1); - - deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); - client.dispute( - &merchant, - &payment_id, - &String::from_str(&env, "hold refund"), - ); - env.ledger().set_sequence_number(111); - client.expire(&payment_id); -} - #[test] fn test_resolve_dispute_to_customer() { -<<<<<<< HEAD - let (env, client, contract_id, admin, customer, merchant, usdc, _xlm) = setup_env(); -======= let (env, client, contract_id, admin, customer, merchant, usdc, ..) = setup_env(); ->>>>>>> pr-956-head let payment_id = make_id(&env, 1); deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); - client.dispute( - &merchant, - &payment_id, - &String::from_str(&env, "chargeback"), - ); + client.dispute(&merchant, &payment_id, &String::from_str(&env, "chargeback")); client.resolve_dispute(&admin, &payment_id, &customer); let payment = client.get_payment(&payment_id); @@ -773,44 +535,12 @@ fn test_resolve_dispute_to_customer() { } #[test] -<<<<<<< HEAD -fn test_cancellation_path_refunds_customer_via_dispute_resolution() { - let (env, client, contract_id, admin, customer, merchant, usdc) = setup_env(); - let payment_id = make_id(&env, 91); - - deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); - client.dispute( - &customer, - &payment_id, - &String::from_str(&env, "cancel requested"), - ); - client.resolve_dispute(&admin, &payment_id, &customer); - - let payment = client.get_payment(&payment_id); - assert_eq!(payment.status, PaymentStatus::Expired); - assert_eq!(client.get_balance(&payment_id), 0); - - let token_client = token::Client::new(&env, &usdc); - assert_eq!(token_client.balance(&customer), 1_000_000_000); - assert_eq!(token_client.balance(&merchant), 0); - assert_eq!(token_client.balance(&contract_id), 0); -} - -#[test] -fn test_resolve_dispute_to_merchant() { - let (env, client, contract_id, admin, customer, merchant, usdc, _xlm) = setup_env(); -======= fn test_resolve_dispute_to_merchant() { let (env, client, contract_id, admin, customer, merchant, usdc, ..) = setup_env(); ->>>>>>> pr-956-head let payment_id = make_id(&env, 1); deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); - client.dispute( - &customer, - &payment_id, - &String::from_str(&env, "investigate"), - ); + client.dispute(&customer, &payment_id, &String::from_str(&env, "investigate")); client.resolve_dispute(&admin, &payment_id, &merchant); let payment = client.get_payment(&payment_id); @@ -825,166 +555,50 @@ fn test_resolve_dispute_to_merchant() { #[test] #[should_panic(expected = "Not admin")] fn test_resolve_dispute_unauthorized() { -<<<<<<< HEAD - let (env, client, _contract_id, _admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= let (env, client, _contract_id, _admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head let payment_id = make_id(&env, 1); let random = Address::generate(&env); deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); - client.dispute( - &customer, - &payment_id, - &String::from_str(&env, "investigate"), - ); + client.dispute(&customer, &payment_id, &String::from_str(&env, "investigate")); client.resolve_dispute(&random, &payment_id, &merchant); } -#[test] -#[should_panic(expected = "Not admin")] -fn test_set_registry_unauthorized() { - let (env, client, _contract_id, _admin, _customer, _merchant, _usdc) = setup_env(); - let random = Address::generate(&env); - let registry = Address::generate(&env); - - client.set_registry(&random, &Some(registry)); -} - #[test] #[should_panic(expected = "Invalid dispute winner")] fn test_resolve_dispute_invalid_winner() { -<<<<<<< HEAD - let (env, client, _contract_id, admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= let (env, client, _contract_id, admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head let payment_id = make_id(&env, 1); let random = Address::generate(&env); deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); - client.dispute( - &merchant, - &payment_id, - &String::from_str(&env, "investigate"), - ); + client.dispute(&merchant, &payment_id, &String::from_str(&env, "investigate")); client.resolve_dispute(&admin, &payment_id, &random); } #[test] -#[should_panic(expected = "Dispute is not open")] -fn test_resolve_dispute_already_resolved() { -<<<<<<< HEAD - let (env, client, _contract_id, admin, customer, merchant, _usdc, _xlm) = setup_env(); -======= - let (env, client, _contract_id, admin, customer, merchant, _usdc, ..) = setup_env(); ->>>>>>> pr-956-head - let payment_id = make_id(&env, 1); +#[should_panic(expected = "Not admin")] +fn test_set_registry_unauthorized() { + let (env, client, _contract_id, _admin, _customer, _merchant, _usdc, ..) = setup_env(); + let random = Address::generate(&env); + let registry = Address::generate(&env); - deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); - client.dispute( - &merchant, - &payment_id, - &String::from_str(&env, "investigate"), - ); - client.resolve_dispute(&admin, &payment_id, &merchant); - client.resolve_dispute(&admin, &payment_id, &merchant); + client.set_registry(&random, &Some(registry)); } // --------------------------------------------------------------------------- -// Merchant registry integration: deposit gating +// Merchant registry integration: deposit gating (#1016) +// _ +// Deploys the real merchant_registry contract as payment_escrow's registry. +// Deposit must be approved only when the merchant is Active AND KYC verified. // --------------------------------------------------------------------------- -fn setup_with_registry() -> ( - Env, - PaymentEscrowContractClient<'static>, - MerchantRegistryContractClient<'static>, - Address, // admin - Address, // customer - Address, // merchant - Address, // usdc - Address, // emergency_signer_one - Address, // emergency_signer_two - Address, // emergency_signer_three - Address, // emergency_treasury -) { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_sequence_number(10); - - let admin = Address::generate(&env); - let customer = Address::generate(&env); - let merchant = Address::generate(&env); - let token_admin = Address::generate(&env); -<<<<<<< HEAD - - let usdc_asset = env.register_stellar_asset_contract_v2(token_admin.clone()); - let usdc = usdc_asset.address(); - let xlm_asset = env.register_stellar_asset_contract_v2(token_admin); - let xlm = xlm_asset.address(); -======= - let asset_contract = env.register_stellar_asset_contract_v2(token_admin); - let usdc = asset_contract.address(); - let emergency_signer_one = Address::generate(&env); - let emergency_signer_two = Address::generate(&env); - let emergency_signer_three = Address::generate(&env); - let emergency_treasury = Address::generate(&env); - let emergency_signers = soroban_sdk::vec![ - &env, - emergency_signer_one.clone(), - emergency_signer_two.clone(), - emergency_signer_three.clone(), - ]; ->>>>>>> pr-956-head - - // Deploy registry and register the merchant. - let registry_id = env.register(MerchantRegistryContract, (&admin,)); - let registry = MerchantRegistryContractClient::new(&env, ®istry_id); - registry.register_merchant(&admin, &merchant, &String::from_str(&env, "Test Merchant")); - - // Deploy escrow wired to registry. - let escrow_id = env.register( - PaymentEscrowContract, -<<<<<<< HEAD - (&admin, &xlm, &usdc, &DEFAULT_PAYMENT_TTL, &Some(registry_id.clone())), -======= - ( - &admin, - &usdc, - &DEFAULT_PAYMENT_TTL, - &Some(registry_id.clone()), - &emergency_signers, - &emergency_treasury, - &EMERGENCY_COOLDOWN_LEDGERS, - ), ->>>>>>> pr-956-head - ); - let escrow = PaymentEscrowContractClient::new(&env, &escrow_id); - - // Mint tokens for customer. - token::StellarAssetClient::new(&env, &usdc).mint(&customer, &1_000_000_000i128); - - ( - env, - escrow, - registry, - admin, - customer, - merchant, - usdc, - emergency_signer_one, - emergency_signer_two, - emergency_signer_three, - emergency_treasury, - ) -} - #[test] -fn test_deposit_allowed_for_active_merchant() { - let (env, escrow, _registry, _admin, customer, merchant, _usdc, ..) = setup_with_registry(); - let payment_id = make_id(&env, 42); +fn test_deposit_allowed_when_registry_approved() { + let (env, escrow, registry, admin, customer, merchant, _usdc, ..) = setup_with_registry(); + registry.set_kyc_status(&admin, &merchant, &true); + let payment_id = make_id(&env, 42); escrow.deposit( &customer, &payment_id, @@ -1000,13 +614,11 @@ fn test_deposit_allowed_for_active_merchant() { } #[test] -#[should_panic(expected = "Merchant is suspended or not registered")] -fn test_deposit_blocked_for_suspended_merchant() { - let (env, escrow, registry, admin, customer, merchant, _usdc, ..) = setup_with_registry(); +#[should_panic(expected = "Merchant is not approved")] +fn test_deposit_blocked_when_kyc_not_verified() { + let (env, escrow, _registry, _admin, customer, merchant, _usdc, ..) = setup_with_registry(); let payment_id = make_id(&env, 43); - registry.suspend_merchant(&admin, &merchant); - escrow.deposit( &customer, &payment_id, @@ -1018,11 +630,11 @@ fn test_deposit_blocked_for_suspended_merchant() { } #[test] -fn test_deposit_allowed_after_reactivation() { +#[should_panic(expected = "Merchant is not approved")] +fn test_deposit_blocked_when_merchant_suspended() { let (env, escrow, registry, admin, customer, merchant, _usdc, ..) = setup_with_registry(); - + registry.set_kyc_status(&admin, &merchant, &true); registry.suspend_merchant(&admin, &merchant); - registry.reactivate_merchant(&admin, &merchant); let payment_id = make_id(&env, 44); escrow.deposit( @@ -1033,18 +645,15 @@ fn test_deposit_allowed_after_reactivation() { &DEFAULT_PAYMENT_TTL, &AssetType::Usdc, ); - - let payment = escrow.get_payment(&payment_id); - assert_eq!(payment.status, PaymentStatus::Pending); } #[test] -#[should_panic(expected = "Merchant is suspended or not registered")] +#[should_panic(expected = "Merchant is not approved")] fn test_deposit_blocked_for_unregistered_merchant() { let (env, escrow, _registry, _admin, customer, _merchant, _usdc, ..) = setup_with_registry(); - let payment_id = make_id(&env, 45); let unknown_merchant = Address::generate(&env); + let payment_id = make_id(&env, 45); escrow.deposit( &customer, &payment_id, @@ -1065,6 +674,7 @@ fn test_emergency_drain_moves_all_funds_to_treasury() { customer, merchant, usdc, + _xlm, emergency_signer_one, emergency_signer_two, _emergency_signer_three, @@ -1072,94 +682,6 @@ fn test_emergency_drain_moves_all_funds_to_treasury() { ) = setup_env(); let payment_id = make_id(&env, 51); -<<<<<<< HEAD - - // Verify the merchant first. - registry.set_kyc_status(&admin, &merchant, &true); - - escrow.deposit( - &customer, - &payment_id, - &merchant, - &250_000_000i128, - &DEFAULT_PAYMENT_TTL, - ); - escrow.release(&admin, &payment_id); - - let payment = escrow.get_payment(&payment_id); - assert_eq!(payment.status, PaymentStatus::Released); - assert_eq!(payment.released_amount, 250_000_000); - - let token_client = token::Client::new(&env, &usdc); - assert_eq!(token_client.balance(&merchant), 250_000_000); -} - -#[test] -fn test_version_initialization() { - let (_env, client, _contract_id, _admin, _customer, _merchant, _usdc) = setup_env(); - assert_eq!(client.get_version(), 1); -} - -#[test] -#[should_panic(expected = "Not admin")] -fn test_upgrade_unauthorized() { - let (env, client, _contract_id, _admin, _customer, _merchant, _usdc) = setup_env(); - let random = Address::generate(&env); - let dummy_hash = BytesN::from_array(&env, &[0; 32]); - - client.upgrade(&random, &dummy_hash); -} - -#[test] -fn test_upgrade_version_increment_stub() { - let (env, client, _contract_id, admin, _customer, _merchant, _usdc) = setup_env(); - - assert_eq!(client.get_version(), 1); - - // We use a dummy hash here. In a real environment or a full integration test, - // this would be a valid WASM hash uploaded to the network. - // For the purpose of this stub test, we are verifying that the version increments - // before the actual WASM update call. - // Note: If update_current_contract_wasm panics on an invalid hash in the test environment, - // this test might fail, but it serves as the required "v2 stub". - - // To make it not fail on the update call if it validates hashes, we'd need a real hash. - // Since we don't have one, we'll just check if it increments. - // In Soroban's current test environment, update_current_contract_wasm might not - // strictly validate the hash if it's not a full integration test. - - let dummy_hash = BytesN::from_array(&env, &[0; 32]); - client.upgrade(&admin, &dummy_hash); - - assert_eq!(client.get_version(), 2); -} - -#[test] -#[should_panic(expected = "Merchant not KYC verified")] -fn test_partial_release_blocked_for_unverified_merchant() { - let (env, escrow, _registry, admin, customer, merchant, _usdc) = setup_with_registry(); - let payment_id = make_id(&env, 52); - - escrow.deposit( - &customer, - &payment_id, - &merchant, - &200_000_000i128, - &DEFAULT_PAYMENT_TTL, - ); - - // Partial release must also be blocked. - escrow.release_partial(&admin, &payment_id, &50_000_000i128); -} - -#[test] -fn test_release_allowed_when_no_registry_configured() { - // Without a registry, KYC is not enforced. - let (env, client, contract_id, admin, customer, merchant, usdc) = setup_env(); - let payment_id = make_id(&env, 53); - -======= ->>>>>>> pr-956-head deposit_default_ttl(&client, &customer, &payment_id, &merchant, 250_000_000i128); let drained = client.emergency_drain( @@ -1185,6 +707,7 @@ fn test_emergency_drain_rejects_non_multisig_signer() { customer, merchant, _usdc, + _xlm, emergency_signer_one, _emergency_signer_two, _emergency_signer_three, @@ -1209,6 +732,7 @@ fn test_emergency_drain_cooldown_prevents_repeated_drain() { customer, merchant, _usdc, + _xlm, emergency_signer_one, emergency_signer_two, _emergency_signer_three, diff --git a/dabdub_contracts/contracts/platform_stats/Cargo.toml b/dabdub_contracts/contracts/platform_stats/Cargo.toml new file mode 100644 index 00000000..951ab4ae --- /dev/null +++ b/dabdub_contracts/contracts/platform_stats/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "platform-stats" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["lib", "cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/dabdub_contracts/contracts/platform_stats/src/lib.rs b/dabdub_contracts/contracts/platform_stats/src/lib.rs new file mode 100644 index 00000000..8ba8fe1f --- /dev/null +++ b/dabdub_contracts/contracts/platform_stats/src/lib.rs @@ -0,0 +1,144 @@ +#![no_std] + +mod test; + +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + +/// Number of ledgers in a ~24h window (5s per ledger). +const ACTIVE_WINDOW_LEDGERS: u32 = 17_280; + +/// Live platform overview metrics for the admin dashboard. +#[contracttype] +#[derive(Clone, Debug)] +pub struct PlatformStats { + pub total_merchants: u32, + pub total_payments: u32, + pub total_settled_volume_usd: i128, + pub active_payments_24h: u32, + pub health: SystemHealth, +} + +/// System health: DB/storage, Stellar connectivity, partner API. +#[contracttype] +#[derive(Clone, Debug)] +pub struct SystemHealth { + pub storage_ok: bool, + pub stellar_ok: bool, + pub partner_ok: bool, +} + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Admin, + TotalMerchants, + TotalPayments, + TotalSettledVolumeUsd, + /// Rolling count of payments in the current 24h bucket. + ActiveBucket(u32), + PartnerOk, +} + +#[contract] +pub struct PlatformStatsContract; + +#[contractimpl] +impl PlatformStatsContract { + pub fn __constructor(env: Env, admin: Address) { + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::TotalMerchants, &0u32); + env.storage().instance().set(&DataKey::TotalPayments, &0u32); + env.storage().instance().set(&DataKey::TotalSettledVolumeUsd, &0i128); + env.storage().instance().set(&DataKey::PartnerOk, &true); + } + + /// Admin-only: record a newly registered merchant. + pub fn record_merchant(env: Env, caller: Address) { + caller.require_auth(); + Self::require_admin(&env, &caller); + let prev: u32 = env.storage().instance().get(&DataKey::TotalMerchants).unwrap(); + env.storage().instance().set(&DataKey::TotalMerchants, &(prev + 1)); + } + + /// Admin-only: record a payment. If `settled`, adds to settled USD volume + /// and increments the rolling 24h active-payments bucket. + pub fn record_payment(env: Env, caller: Address, amount_usd: i128, settled: bool) { + caller.require_auth(); + Self::require_admin(&env, &caller); + + let tp: u32 = env.storage().instance().get(&DataKey::TotalPayments).unwrap(); + env.storage().instance().set(&DataKey::TotalPayments, &(tp + 1)); + + if settled { + let vol: i128 = env + .storage() + .instance() + .get(&DataKey::TotalSettledVolumeUsd) + .unwrap(); + env.storage() + .instance() + .set(&DataKey::TotalSettledVolumeUsd, &(vol + amount_usd)); + } + + let bucket = env.ledger().sequence() / ACTIVE_WINDOW_LEDGERS; + let key = DataKey::ActiveBucket(bucket); + let count: u32 = env.storage().instance().get(&key).unwrap_or(0); + env.storage().instance().set(&key, &(count + 1)); + } + + /// Admin-only: reflect partner API health status. + pub fn set_partner_ok(env: Env, caller: Address, ok: bool) { + caller.require_auth(); + Self::require_admin(&env, &caller); + env.storage().instance().set(&DataKey::PartnerOk, &ok); + } + + /// Live overview metrics, computed directly from storage. + pub fn stats(env: Env) -> PlatformStats { + let bucket = env.ledger().sequence() / ACTIVE_WINDOW_LEDGERS; + let active: u32 = env + .storage() + .instance() + .get(&DataKey::ActiveBucket(bucket)) + .unwrap_or(0); + + PlatformStats { + total_merchants: env + .storage() + .instance() + .get(&DataKey::TotalMerchants) + .unwrap_or(0), + total_payments: env + .storage() + .instance() + .get(&DataKey::TotalPayments) + .unwrap_or(0), + total_settled_volume_usd: env + .storage() + .instance() + .get(&DataKey::TotalSettledVolumeUsd) + .unwrap_or(0), + active_payments_24h: active, + health: Self::health(&env), + } + } + + fn require_admin(env: &Env, caller: &Address) { + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + if caller != &admin { + panic!("not admin"); + } + } + + fn health(env: &Env) -> SystemHealth { + SystemHealth { + storage_ok: env.storage().instance().has(&DataKey::Admin), + stellar_ok: env.ledger().sequence() > 0, + partner_ok: env + .storage() + .instance() + .get(&DataKey::PartnerOk) + .unwrap_or(true), + } + } +} diff --git a/dabdub_contracts/contracts/platform_stats/src/test.rs b/dabdub_contracts/contracts/platform_stats/src/test.rs new file mode 100644 index 00000000..8ab90ff9 --- /dev/null +++ b/dabdub_contracts/contracts/platform_stats/src/test.rs @@ -0,0 +1,65 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{testutils::Address as _, Env}; + +fn setup() -> (Env, PlatformStatsContractClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let id = env.register(PlatformStatsContract, (&admin,)); + let client = PlatformStatsContractClient::new(&env, &id); + (env, client, admin) +} + +#[test] +fn test_initial_stats_are_zero() { + let (env, client, _admin) = setup(); + let stats = client.stats(); + assert_eq!(stats.total_merchants, 0); + assert_eq!(stats.total_payments, 0); + assert_eq!(stats.total_settled_volume_usd, 0); + assert_eq!(stats.active_payments_24h, 0); + assert!(stats.health.storage_ok); + assert!(stats.health.stellar_ok); + assert!(stats.health.partner_ok); +} + +#[test] +fn test_record_merchant_and_payment() { + let (_env, client, admin) = setup(); + client.record_merchant(&admin); + client.record_payment(&admin, &10_000_000i128, &true); + client.record_payment(&admin, &5_000_000i128, &true); + client.record_payment(&admin, &7_000_000i128, &false); + + let stats = client.stats(); + assert_eq!(stats.total_merchants, 1); + assert_eq!(stats.total_payments, 3); + assert_eq!(stats.total_settled_volume_usd, 15_000_000); + assert_eq!(stats.active_payments_24h, 3); +} + +#[test] +#[should_panic(expected = "not admin")] +fn test_record_merchant_requires_admin() { + let (env, client, _admin) = setup(); + let attacker = Address::generate(&env); + client.record_merchant(&attacker); +} + +#[test] +#[should_panic(expected = "not admin")] +fn test_record_payment_requires_admin() { + let (env, client, _admin) = setup(); + let attacker = Address::generate(&env); + client.record_payment(&attacker, &100i128, &true); +} + +#[test] +fn test_partner_health_status_toggle() { + let (env, client, admin) = setup(); + assert!(client.stats().health.partner_ok); + client.set_partner_ok(&admin, &false); + assert!(!client.stats().health.partner_ok); +}