From 4b3785a9349d7659d1347c3dfdec6638ce7359d4 Mon Sep 17 00:00:00 2001 From: lawsonemmanuel207-hash Date: Mon, 22 Jun 2026 13:12:49 +0100 Subject: [PATCH 1/2] Add vendor approval flow with VendorStatus enum Replaced the active: bool field in VendorInfo with a VendorStatus enum (Pending, Approved, Suspended, Rejected). register_vendor() now sets new vendors to Pending instead of immediately active. Added admin-only approve_vendor() and suspend_vendor() functions. Updated is_active() to return true only for Approved vendors, which automatically gates loan creation in creditline-contract via its existing validate_vendor() call. Legacy functions (activate_vendor, deactivate_vendor, set_vendor_status) map to the new enum for backward compatibility. Updated all tests and creditline test helpers to register + approve vendors before creating loans. Files touched: vendor-registry-contract/src/lib.rs, vendor-registry-contract/src/types.rs, vendor-registry-contract/src/events.rs, vendor-registry-contract/src/errors.rs, vendor-registry-contract/src/tests.rs, creditline-contract/src/tests.rs Closed #23 --- context/progress-tracker.md | 14 ++ contracts/creditline-contract/src/tests.rs | 25 +++ .../vendor-registry-contract/src/errors.rs | 1 + .../vendor-registry-contract/src/events.rs | 5 +- contracts/vendor-registry-contract/src/lib.rs | 74 ++++++-- .../vendor-registry-contract/src/tests.rs | 178 ++++++++++++++++-- .../vendor-registry-contract/src/types.rs | 11 +- 7 files changed, 281 insertions(+), 27 deletions(-) diff --git a/context/progress-tracker.md b/context/progress-tracker.md index c820f79..4e044e1 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -16,6 +16,20 @@ Update this file after every completed contract change, fix, or architectural de ## Completed +### Issue #7 — Vendor Approval Flow +- Added `VendorStatus` enum (`Pending`, `Approved`, `Suspended`, `Rejected`) to `types.rs` +- Replaced `active: bool` with `status: VendorStatus` in `VendorInfo` +- `register_vendor()` now sets `status = VendorStatus::Pending` instead of immediately active +- Added `approve_vendor()` (admin-only, requires Pending → Approved) +- Added `suspend_vendor()` (admin-only, any status → Suspended) +- `is_active()` returns `true` only for `Approved` vendors — automatically prevents unapproved/suspended vendors from receiving loans in `creditline-contract` +- Legacy functions (`activate_vendor`, `deactivate_vendor`, `set_vendor_status`) updated to map to new enum +- New error: `VendorNotPending = 10` in `vendor-registry-contract` +- Updated `publish_vendor_status` event to emit `VendorStatus` instead of `bool` +- All vendor-registry tests updated; 7 new tests added (approval flow, non-pending rejection, suspension, re-approval, reentrancy guards for approve/suspend) +- Creditline tests updated to approve vendors after registration +- No changes needed to `creditline-contract/src/lib.rs` — `validate_vendor()` already uses `is_active()` which now checks for `Approved` + ### Workspace Cleanup - Removed dead code: `lp-contract` (superseded by `liquidity-pool-contract`) - Removed empty placeholder: `adapter-trustless-contract` diff --git a/contracts/creditline-contract/src/tests.rs b/contracts/creditline-contract/src/tests.rs index c96cb74..8219e94 100644 --- a/contracts/creditline-contract/src/tests.rs +++ b/contracts/creditline-contract/src/tests.rs @@ -154,6 +154,7 @@ impl TestCtx { } /// Register a vendor in the vendor registry (idempotent - won't fail if already registered) + /// and approve it so loans can be created. fn register_vendor(&self, vendor: &Address, name: &str) { use soroban_sdk::{IntoVal, Symbol}; let vendor_name = SorobanString::from_str(&self.env, name); @@ -165,6 +166,12 @@ impl TestCtx { &Symbol::new(&self.env, "register_vendor"), (&self.admin, vendor, vendor_name).into_val(&self.env), ); + // Approve the vendor so create_loan can proceed + let _ = self.env.try_invoke_contract::<(), soroban_sdk::Error>( + &self.vendor_registry_id, + &Symbol::new(&self.env, "approve_vendor"), + (&self.admin, vendor).into_val(&self.env), + ); } /// Create a loan with sensible defaults: total=1000, guarantee=200, 1 installment. @@ -887,6 +894,12 @@ fn test_mark_defaulted_success() { &Symbol::new(&env, "register_vendor"), (&admin, &vendor, vendor_name).into_val(&env), ); + // Approve the vendor + let _: Result<(), vendor_registry_contract::VendorRegistryError> = env.invoke_contract( + &vendor_registry_id, + &Symbol::new(&env, "approve_vendor"), + (&admin, &vendor).into_val(&env), + ); client.initialize( &admin, @@ -962,6 +975,12 @@ fn test_mark_defaulted_too_early_fails() { &Symbol::new(&env, "register_vendor"), (&admin, &vendor, vendor_name).into_val(&env), ); + // Approve the vendor + let _: Result<(), vendor_registry_contract::VendorRegistryError> = env.invoke_contract( + &vendor_registry_id, + &Symbol::new(&env, "approve_vendor"), + (&admin, &vendor).into_val(&env), + ); client.initialize( &admin, @@ -1151,6 +1170,12 @@ fn test_create_loan_rejected_when_reputation_below_threshold() { &Symbol::new(&env, "register_vendor"), (&admin, &vendor, vendor_name).into_val(&env), ); + // Approve the vendor + let _: Result<(), vendor_registry_contract::VendorRegistryError> = env.invoke_contract( + &vendor_registry_id, + &Symbol::new(&env, "approve_vendor"), + (&admin, &vendor).into_val(&env), + ); client.initialize( &admin, diff --git a/contracts/vendor-registry-contract/src/errors.rs b/contracts/vendor-registry-contract/src/errors.rs index 2366651..dfa2c0c 100644 --- a/contracts/vendor-registry-contract/src/errors.rs +++ b/contracts/vendor-registry-contract/src/errors.rs @@ -13,4 +13,5 @@ pub enum Error { Overflow = 7, ReentrancyDetected = 8, Underflow = 9, + VendorNotPending = 10, } diff --git a/contracts/vendor-registry-contract/src/events.rs b/contracts/vendor-registry-contract/src/events.rs index 31dc270..9358837 100644 --- a/contracts/vendor-registry-contract/src/events.rs +++ b/contracts/vendor-registry-contract/src/events.rs @@ -1,3 +1,4 @@ +use crate::types::VendorStatus; use soroban_sdk::{Address, Env, String, Symbol}; pub fn publish_vendor_registered(env: &Env, vendor: Address, name: String) { @@ -5,9 +6,9 @@ pub fn publish_vendor_registered(env: &Env, vendor: Address, name: String) { env.events().publish(topics, name); } -pub fn publish_vendor_status(env: &Env, vendor: Address, active: bool) { +pub fn publish_vendor_status(env: &Env, vendor: Address, status: VendorStatus) { let topics = (Symbol::new(env, "MERCHTSTATUS"), vendor); - env.events().publish(topics, active); + env.events().publish(topics, status); } pub fn emit_contract_upgraded(env: &Env, old_version: u32, new_version: u32) { diff --git a/contracts/vendor-registry-contract/src/lib.rs b/contracts/vendor-registry-contract/src/lib.rs index c4c193e..2ac730a 100644 --- a/contracts/vendor-registry-contract/src/lib.rs +++ b/contracts/vendor-registry-contract/src/lib.rs @@ -12,7 +12,7 @@ mod tests; use errors::Error; use soroban_sdk::{contract, contractimpl, Address, Env, String}; -use types::VendorInfo; +use types::{VendorInfo, VendorStatus}; // Export Error type for external use pub use errors::Error as VendorRegistryError; @@ -71,7 +71,7 @@ impl VendorRegistryContract { let info = VendorInfo { name: name.clone(), registration_date: env.ledger().timestamp(), - active: true, + status: VendorStatus::Pending, total_sales: 0, }; @@ -84,7 +84,50 @@ impl VendorRegistryContract { Ok(()) } - /// Deactivates an existing vendor + /// Approves a pending vendor so they can receive loans + pub fn approve_vendor(env: Env, admin: Address, vendor: Address) -> Result<(), Error> { + if !storage::has_admin(&env) { + return Err(Error::NotInitialized); + } + + access::require_admin(&env, &admin)?; + + Self::check_non_reentrant(&env)?; + + let mut info = storage::get_vendor(&env, &vendor)?; + if info.status != VendorStatus::Pending { + return Err(Error::VendorNotPending); + } + info.status = VendorStatus::Approved; + storage::set_vendor(&env, &vendor, &info); + events::publish_vendor_status(&env, vendor, VendorStatus::Approved); + + Self::exit_non_reentrant(&env); + + Ok(()) + } + + /// Suspends an approved vendor, preventing new loans + pub fn suspend_vendor(env: Env, admin: Address, vendor: Address) -> Result<(), Error> { + if !storage::has_admin(&env) { + return Err(Error::NotInitialized); + } + + access::require_admin(&env, &admin)?; + + Self::check_non_reentrant(&env)?; + + let mut info = storage::get_vendor(&env, &vendor)?; + info.status = VendorStatus::Suspended; + storage::set_vendor(&env, &vendor, &info); + events::publish_vendor_status(&env, vendor, VendorStatus::Suspended); + + Self::exit_non_reentrant(&env); + + Ok(()) + } + + /// Deactivates an existing vendor (legacy — maps to Suspended) pub fn deactivate_vendor(env: Env, admin: Address, vendor: Address) -> Result<(), Error> { if !storage::has_admin(&env) { return Err(Error::NotInitialized); @@ -95,16 +138,16 @@ impl VendorRegistryContract { Self::check_non_reentrant(&env)?; let mut info = storage::get_vendor(&env, &vendor)?; - info.active = false; + info.status = VendorStatus::Suspended; storage::set_vendor(&env, &vendor, &info); - events::publish_vendor_status(&env, vendor, false); + events::publish_vendor_status(&env, vendor, VendorStatus::Suspended); Self::exit_non_reentrant(&env); Ok(()) } - /// Activates an existing vendor + /// Activates an existing vendor (legacy — maps to Approved) pub fn activate_vendor(env: Env, admin: Address, vendor: Address) -> Result<(), Error> { if !storage::has_admin(&env) { return Err(Error::NotInitialized); @@ -115,17 +158,17 @@ impl VendorRegistryContract { Self::check_non_reentrant(&env)?; let mut info = storage::get_vendor(&env, &vendor)?; - info.active = true; + info.status = VendorStatus::Approved; storage::set_vendor(&env, &vendor, &info); - events::publish_vendor_status(&env, vendor, true); + events::publish_vendor_status(&env, vendor, VendorStatus::Approved); Self::exit_non_reentrant(&env); Ok(()) } - /// Sets a vendor's active status (admin only). - /// Pass `active = true` to activate, `active = false` to deactivate. + /// Sets a vendor's active status (admin only, legacy). + /// Pass `active = true` to approve, `active = false` to suspend. pub fn set_vendor_status( env: Env, admin: Address, @@ -141,9 +184,14 @@ impl VendorRegistryContract { Self::check_non_reentrant(&env)?; let mut info = storage::get_vendor(&env, &vendor)?; - info.active = active; + let new_status = if active { + VendorStatus::Approved + } else { + VendorStatus::Suspended + }; + info.status = new_status.clone(); storage::set_vendor(&env, &vendor, &info); - events::publish_vendor_status(&env, vendor, active); + events::publish_vendor_status(&env, vendor, new_status); Self::exit_non_reentrant(&env); @@ -152,7 +200,7 @@ impl VendorRegistryContract { pub fn is_active(env: Env, vendor: Address) -> bool { storage::get_vendor(&env, &vendor) - .map(|info| info.active) + .map(|info| info.status == VendorStatus::Approved) .unwrap_or(false) } diff --git a/contracts/vendor-registry-contract/src/tests.rs b/contracts/vendor-registry-contract/src/tests.rs index 27757a7..09254e2 100644 --- a/contracts/vendor-registry-contract/src/tests.rs +++ b/contracts/vendor-registry-contract/src/tests.rs @@ -58,13 +58,14 @@ fn test_registration_flow() { let name = String::from_str(&env, "Galaxy Tech Supplies"); client.register_vendor(&admin, &vendor, &name); - assert!(client.is_active(&vendor)); + // Vendor starts as Pending, not active + assert!(!client.is_active(&vendor)); // get_vendor_info automatically unwraps on success in the test client let info = client.get_vendor_info(&vendor); assert_eq!(info.name, name); assert_eq!(info.registration_date, 1000000); - assert!(info.active); + assert_eq!(info.status, types::VendorStatus::Pending); assert_eq!(info.total_sales, 0); assert_eq!(client.get_vendor_count(), 1); } @@ -107,17 +108,36 @@ fn test_activation_and_deactivation() { let name = String::from_str(&env, "Nebula Cafe"); client.register_vendor(&admin, &vendor, &name); + // Vendor starts as Pending (not active) + assert!(!client.is_active(&vendor)); + assert_eq!( + client.get_vendor_info(&vendor).status, + types::VendorStatus::Pending + ); + + // Approve vendor + client.approve_vendor(&admin, &vendor); assert!(client.is_active(&vendor)); + assert_eq!( + client.get_vendor_info(&vendor).status, + types::VendorStatus::Approved + ); - // Deactivate vendor - client.deactivate_vendor(&admin, &vendor); + // Suspend vendor + client.suspend_vendor(&admin, &vendor); assert!(!client.is_active(&vendor)); - assert!(!client.get_vendor_info(&vendor).active); + assert_eq!( + client.get_vendor_info(&vendor).status, + types::VendorStatus::Suspended + ); - // Activate vendor + // Reactivate via legacy activate_vendor client.activate_vendor(&admin, &vendor); assert!(client.is_active(&vendor)); - assert!(client.get_vendor_info(&vendor).active); + assert_eq!( + client.get_vendor_info(&vendor).status, + types::VendorStatus::Approved + ); } #[test] @@ -129,16 +149,28 @@ fn test_set_vendor_status() { let name = String::from_str(&env, "Quasar Goods"); client.register_vendor(&admin, &vendor, &name); - // Vendor starts active - assert!(client.is_active(&vendor)); + // Vendor starts as Pending (not active) + assert!(!client.is_active(&vendor)); + assert_eq!( + client.get_vendor_info(&vendor).status, + types::VendorStatus::Pending + ); - // Deactivate via set_vendor_status + // Deactivate via set_vendor_status (false → Suspended) client.set_vendor_status(&admin, &vendor, &false); assert!(!client.is_active(&vendor)); + assert_eq!( + client.get_vendor_info(&vendor).status, + types::VendorStatus::Suspended + ); - // Reactivate via set_vendor_status + // Reactivate via set_vendor_status (true → Approved) client.set_vendor_status(&admin, &vendor, &true); assert!(client.is_active(&vendor)); + assert_eq!( + client.get_vendor_info(&vendor).status, + types::VendorStatus::Approved + ); // Non-admin must be rejected let fake_admin = Address::generate(&env); @@ -146,6 +178,85 @@ fn test_set_vendor_status() { assert!(res.is_err()); } +#[test] +fn test_approve_vendor_sets_status_to_approved() { + let env = Env::default(); + let (client, admin, vendor) = setup(&env); + env.mock_all_auths(); + + let name = String::from_str(&env, "Approve Me"); + client.register_vendor(&admin, &vendor, &name); + + assert_eq!( + client.get_vendor_info(&vendor).status, + types::VendorStatus::Pending + ); + + client.approve_vendor(&admin, &vendor); + assert_eq!( + client.get_vendor_info(&vendor).status, + types::VendorStatus::Approved + ); + assert!(client.is_active(&vendor)); +} + +#[test] +fn test_approve_vendor_rejects_non_pending() { + let env = Env::default(); + let (client, admin, vendor) = setup(&env); + env.mock_all_auths(); + + let name = String::from_str(&env, "Already Approved"); + client.register_vendor(&admin, &vendor, &name); + client.approve_vendor(&admin, &vendor); + + // Approving an already approved vendor should fail + let res = client.try_approve_vendor(&admin, &vendor); + assert_eq!(res, Err(Ok(Error::VendorNotPending))); +} + +#[test] +fn test_suspend_vendor_sets_status_to_suspended() { + let env = Env::default(); + let (client, admin, vendor) = setup(&env); + env.mock_all_auths(); + + let name = String::from_str(&env, "Suspend Me"); + client.register_vendor(&admin, &vendor, &name); + client.approve_vendor(&admin, &vendor); + + assert!(client.is_active(&vendor)); + + client.suspend_vendor(&admin, &vendor); + assert_eq!( + client.get_vendor_info(&vendor).status, + types::VendorStatus::Suspended + ); + assert!(!client.is_active(&vendor)); +} + +#[test] +fn test_suspended_vendor_can_be_approved_again() { + let env = Env::default(); + let (client, admin, vendor) = setup(&env); + env.mock_all_auths(); + + let name = String::from_str(&env, "Back and Forth"); + client.register_vendor(&admin, &vendor, &name); + client.approve_vendor(&admin, &vendor); + client.suspend_vendor(&admin, &vendor); + + assert!(!client.is_active(&vendor)); + + // Re-approve via legacy activate_vendor + client.activate_vendor(&admin, &vendor); + assert!(client.is_active(&vendor)); + assert_eq!( + client.get_vendor_info(&vendor).status, + types::VendorStatus::Approved + ); +} + // ============================================================================ // Reentrancy Guard Tests // ============================================================================ @@ -270,6 +381,51 @@ fn test_reentrancy_guard_is_released_after_call() { client.activate_vendor(&admin, &vendor); } +#[test] +fn test_reentrancy_guard_on_approve_vendor() { + let env = Env::default(); + let contract_id = env.register(VendorRegistryContract, ()); + let client = VendorRegistryContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let vendor = Address::generate(&env); + + client.initialize(&admin); + env.mock_all_auths(); + + let name = String::from_str(&env, "Test"); + client.register_vendor(&admin, &vendor, &name); + + env.as_contract(&contract_id, || { + env.storage().instance().set(&types::DataKey::Locked, &true); + }); + + let res = client.try_approve_vendor(&admin, &vendor); + assert_eq!(res, Err(Ok(Error::ReentrancyDetected))); +} + +#[test] +fn test_reentrancy_guard_on_suspend_vendor() { + let env = Env::default(); + let contract_id = env.register(VendorRegistryContract, ()); + let client = VendorRegistryContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let vendor = Address::generate(&env); + + client.initialize(&admin); + env.mock_all_auths(); + + let name = String::from_str(&env, "Test"); + client.register_vendor(&admin, &vendor, &name); + client.approve_vendor(&admin, &vendor); + + env.as_contract(&contract_id, || { + env.storage().instance().set(&types::DataKey::Locked, &true); + }); + + let res = client.try_suspend_vendor(&admin, &vendor); + assert_eq!(res, Err(Ok(Error::ReentrancyDetected))); +} + #[test] #[should_panic(expected = "Error(Contract")] // non-admin upgrade rejected fn test_upgrade_rejected_for_non_admin() { diff --git a/contracts/vendor-registry-contract/src/types.rs b/contracts/vendor-registry-contract/src/types.rs index a58ffc6..b06cb8f 100644 --- a/contracts/vendor-registry-contract/src/types.rs +++ b/contracts/vendor-registry-contract/src/types.rs @@ -12,11 +12,20 @@ pub enum DataKey { VendorCount, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum VendorStatus { + Pending = 0, + Approved = 1, + Suspended = 2, + Rejected = 3, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct VendorInfo { pub name: String, pub registration_date: u64, - pub active: bool, + pub status: VendorStatus, pub total_sales: u64, } From 1a765da3b2b5f065c7f6be3434fac4b3cb0a09cf Mon Sep 17 00:00:00 2001 From: lawsonemmanuel207-hash Date: Mon, 22 Jun 2026 14:38:30 +0100 Subject: [PATCH 2/2] fixed ci issue --- context/progress-tracker.md | 7 +++++++ contracts/creditline-contract/src/tests.rs | 2 ++ 2 files changed, 9 insertions(+) diff --git a/context/progress-tracker.md b/context/progress-tracker.md index 4e044e1..eab891a 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -96,6 +96,13 @@ Update this file after every completed contract change, fix, or architectural de - None currently. +## Recently Fixed + +### Issue #7 — Follow-up: Missing `approve_vendor` in `RealIntegrationCtx::register_vendor` +- Discovered second `register_vendor` helper in `RealIntegrationCtx` (integration test struct, ~line 2390) that only called `register_vendor` without `approve_vendor` +- All integration tests using `RealIntegrationCtx` created loans with `Pending` vendors → `validate_vendor` → `is_active` returned `false` → `VendorNotActive` (#3) +- Added `self.vendor_registry.approve_vendor(&self.admin, vendor)` after registration in `RealIntegrationCtx::register_vendor` + --- ## Next Up (In Order) diff --git a/contracts/creditline-contract/src/tests.rs b/contracts/creditline-contract/src/tests.rs index 8219e94..082f0f5 100644 --- a/contracts/creditline-contract/src/tests.rs +++ b/contracts/creditline-contract/src/tests.rs @@ -2391,6 +2391,8 @@ impl RealIntegrationCtx { let vendor_name = SorobanString::from_str(&self.env, name); self.vendor_registry .register_vendor(&self.admin, vendor, &vendor_name); + self.vendor_registry + .approve_vendor(&self.admin, vendor); } fn single_installment(