diff --git a/README.md b/README.md index 067485a..0446dca 100644 --- a/README.md +++ b/README.md @@ -243,9 +243,33 @@ chainsetttle-contract/ ├── Makefile ← build / deploy shortcuts └── src/ ├── lib.rs ← main contract logic - └── test.rs ← unit tests + ├── test.rs ← test module orchestrator + ├── test_common.rs ← shared test setup, fixtures, helpers + ├── test_shipment.rs ← shipment lifecycle tests (create, confirm, cancel) + ├── test_dispute.rs ← dispute workflow tests (raise, resolve, cooldown) + ├── test_admin.rs ← admin control tests (pause, blacklist, settings) + ├── test_query.rs ← read-only query tests (completion %) + ├── constants.rs ← contract constants + ├── storage.rs ← storage layer + ├── admin.rs ← admin functions + ├── benchmarks.rs ← performance benchmarks + └── [other test files] ← edge cases, stress tests, advanced features ``` +### Test File Organization + +Tests are split by domain for clarity and to reduce merge conflicts: + +| File | Purpose | Example Tests | +|------|---------|---| +| `test_common.rs` | Shared setup & utilities | `setup()`, `build_milestones()`, `create_standard_shipment()` | +| `test_shipment.rs` | Shipment lifecycle | `test_create_shipment_success`, `test_full_shipment_lifecycle`, `test_cancel_shipment` | +| `test_dispute.rs` | Dispute resolution | `test_raise_dispute`, `test_resolve_dispute`, `test_dispute_cooldown_enforced` | +| `test_admin.rs` | Admin controls | `test_pause_blocks_create_shipment`, `test_blacklist_removal_restores_participation` | +| `test_query.rs` | Read-only queries | `test_get_completion_percentage_*` | + +All shared fixtures and helper functions are centralized in `test_common.rs` to avoid duplication. + --- ## Prerequisites diff --git a/contracts/chainsetttle/src/lib.rs b/contracts/chainsetttle/src/lib.rs index f77c6a9..a14c07c 100644 --- a/contracts/chainsetttle/src/lib.rs +++ b/contracts/chainsetttle/src/lib.rs @@ -1,7 +1,7 @@ #![no_std] use soroban_sdk::{ - contract, contractclient, contractimpl, contracttype, token, Address, BytesN, Env, String, Vec, Symbol, + contract, contractimpl, contracterror, contracttype, token, Address, BytesN, Env, String, Vec, Symbol, }; // ============================================================ @@ -283,6 +283,52 @@ pub struct AdminAction { pub params: String, } +// ============================================================ +// STORAGE CONTEXT STRUCTS (batch reads) +// ============================================================ + +/// CreateShipmentCtx consolidates all persistent storage reads for create_shipment. +/// Keys accessed: +/// - DataKey::MaxShipmentValue (instance) +/// - DataKey::AllowedTokens (instance) +/// - DataKey::Blacklisted(Address) (instance) × (buyers + 3 others) +/// - DataKey::MinMilestonePercent (instance) +/// - DataKey::Shipment(shipment_id) (persistent) +/// - DataKey::TotalEscrowed(token) (persistent) +/// - DataKey::ContractStats (instance) +#[derive(Clone)] +pub struct CreateShipmentCtx { + pub max_value: i128, + pub allowed_tokens: Vec
, + pub min_pct: u32, + pub contract_stats: ContractStats, +} + +/// ConfirmMilestoneCtx consolidates all persistent storage reads for confirm_milestone. +/// Keys accessed: +/// - DataKey::Shipment(shipment_id) (persistent) +/// - DataKey::ContractStats (instance) +/// - DataKey::TotalEscrowed(token) (persistent) +#[derive(Clone)] +pub struct ConfirmMilestoneCtx { + pub shipment: Shipment, + pub contract_stats: ContractStats, +} + +/// ResolveDisputeCtx consolidates all persistent storage reads for resolve_dispute. +/// Keys accessed: +/// - DataKey::Shipment(shipment_id) (persistent) +/// - DataKey::DisputeContestedPercent(shipment_id, milestone_index) (persistent) +/// - DataKey::ContractStats (instance) +/// - DataKey::ActiveDisputes (persistent) +#[derive(Clone)] +pub struct ResolveDisputeCtx { + pub shipment: Shipment, + pub partial_contested_percent: Option, + pub contract_stats: ContractStats, + pub active_disputes: Vec, +} + // ============================================================ // STORAGE KEYS // ============================================================ @@ -932,26 +978,18 @@ impl ChainSettleContract { panic!("amount must be greater than zero"); } - // Check max shipment value cap. - let max_value: i128 = env - .storage() - .instance() - .get(&DataKey::MaxShipmentValue) - .unwrap_or(0); - if max_value > 0 && total_amount > max_value { + // Batch read all validation config and stats in a single context fetch. + let ctx = Self::fetch_create_shipment_ctx(&env); + + if ctx.max_value > 0 && total_amount > ctx.max_value { panic!("total amount exceeds maximum shipment value"); } // Enforce token whitelist when non-empty. - let allowed: Vec
= env - .storage() - .instance() - .get(&DataKey::AllowedTokens) - .unwrap_or_else(|| Vec::new(&env)); - if allowed.len() > 0 { + if ctx.allowed_tokens.len() > 0 { let mut found = false; - for i in 0..allowed.len() { - if allowed.get(i).unwrap() == token { + for i in 0..ctx.allowed_tokens.len() { + if ctx.allowed_tokens.get(i).unwrap() == token { found = true; break; } @@ -982,11 +1020,7 @@ impl ChainSettleContract { } } - let min_pct: u32 = env - .storage() - .instance() - .get(&DataKey::MinMilestonePercent) - .unwrap_or(5u32); + let min_pct = ctx.min_pct; let mut total_percent: u32 = 0; for i in 0..milestones.len() { let percent = milestones.get(i).unwrap().payment_percent; @@ -1167,16 +1201,7 @@ impl ChainSettleContract { ); // Update contract stats. - let mut stats: ContractStats = env - .storage() - .instance() - .get(&DataKey::ContractStats) - .unwrap_or(ContractStats { - total_shipments: 0, - total_volume: 0, - total_disputes: 0, - completed_shipments: 0, - }); + let mut stats = ctx.contract_stats; stats.total_shipments += 1; stats.total_volume += total_amount; env.storage() @@ -1627,7 +1652,9 @@ impl ChainSettleContract { env.storage().instance().extend_ttl(100_000, 6_300_000); Self::assert_not_paused(&env); - let mut shipment = Self::get_shipment_internal(&env, &shipment_id); + // Batch read shipment and contract stats in a single context fetch. + let ctx = Self::fetch_confirm_milestone_ctx(&env, &shipment_id); + let mut shipment = ctx.shipment; if shipment.status != ShipmentStatus::Active { panic!("shipment is not active"); @@ -1744,17 +1771,8 @@ impl ChainSettleContract { if Self::all_milestones_done(&shipment) { shipment.status = ShipmentStatus::Completed; - // Update completed shipments stat. - let mut stats: ContractStats = env - .storage() - .instance() - .get(&DataKey::ContractStats) - .unwrap_or(ContractStats { - total_shipments: 0, - total_volume: 0, - total_disputes: 0, - completed_shipments: 0, - }); + // Update completed shipments stat using pre-fetched context. + let mut stats = ctx.contract_stats; stats.completed_shipments += 1; env.storage() .instance() @@ -2402,7 +2420,9 @@ impl ChainSettleContract { ) { Self::assert_not_paused(&env); - let mut shipment = Self::get_shipment_internal(&env, &shipment_id); + // Batch read shipment, dispute status, stats and active disputes in a single context fetch. + let ctx = Self::fetch_resolve_dispute_ctx(&env, &shipment_id, milestone_index); + let mut shipment = ctx.shipment; if shipment.status != ShipmentStatus::Active { panic!("shipment is not active"); @@ -2414,19 +2434,15 @@ impl ChainSettleContract { panic!("milestone is not in disputed status"); } - // Detect whether this is a partial dispute. - let contested_key = - DataKey::DisputeContestedPercent(shipment_id.clone(), milestone_index); - let partial_contested_percent: Option = - env.storage().persistent().get(&contested_key); - let is_partial = partial_contested_percent.is_some(); + // Use pre-fetched partial contested percent from context. + let is_partial = ctx.partial_contested_percent.is_some(); let full_payment = (shipment.total_amount * milestone.payment_percent as i128) / 100; // The "payment" in scope is the portion subject to this resolution: // - full dispute → 100% of milestone value // - partial dispute → contested_percent% of milestone value - let payment = if let Some(cp) = partial_contested_percent { + let payment = if let Some(cp) = ctx.partial_contested_percent { (full_payment * cp as i128) / 100 } else { full_payment @@ -2531,6 +2547,8 @@ impl ChainSettleContract { } // Clean up the partial-dispute record. + let contested_key = + DataKey::DisputeContestedPercent(shipment_id.clone(), milestone_index); if is_partial { env.storage().persistent().remove(&contested_key); } @@ -2543,16 +2561,7 @@ impl ChainSettleContract { if Self::all_milestones_done(&shipment) { shipment.status = ShipmentStatus::Completed; - let mut stats: ContractStats = env - .storage() - .instance() - .get(&DataKey::ContractStats) - .unwrap_or(ContractStats { - total_shipments: 0, - total_volume: 0, - total_disputes: 0, - completed_shipments: 0, - }); + let mut stats = ctx.contract_stats; stats.completed_shipments += 1; env.storage() .instance() @@ -2570,15 +2579,10 @@ impl ChainSettleContract { .persistent() .set(&DataKey::Shipment(shipment_id.clone()), &shipment); - // Remove from active disputes list. - let disputes: Vec = env - .storage() - .persistent() - .get(&DataKey::ActiveDisputes) - .unwrap_or_else(|| Vec::new(&env)); + // Remove from active disputes list using pre-fetched disputes. let mut new_disputes: Vec = Vec::new(&env); - for i in 0..disputes.len() { - let d = disputes.get(i).unwrap(); + for i in 0..ctx.active_disputes.len() { + let d = ctx.active_disputes.get(i).unwrap(); if !(d.shipment_id == shipment_id && d.milestone_index == milestone_index) { new_disputes.push_back(d); } @@ -4038,6 +4042,103 @@ impl ChainSettleContract { Self::set_reputation_internal(env, supplier, &score); } + // ============================================================ + // STORAGE CONTEXT HELPERS (batch reads) + // ============================================================ + + /// Fetch CreateShipmentCtx: consolidates all validation storage reads for create_shipment. + /// Keys accessed: MaxShipmentValue, AllowedTokens, MinMilestonePercent, ContractStats. + fn fetch_create_shipment_ctx(env: &Env) -> CreateShipmentCtx { + let max_value: i128 = env + .storage() + .instance() + .get(&DataKey::MaxShipmentValue) + .unwrap_or(0); + let allowed_tokens: Vec
= env + .storage() + .instance() + .get(&DataKey::AllowedTokens) + .unwrap_or_else(|| Vec::new(env)); + let min_pct: u32 = env + .storage() + .instance() + .get(&DataKey::MinMilestonePercent) + .unwrap_or(5u32); + let contract_stats: ContractStats = env + .storage() + .instance() + .get(&DataKey::ContractStats) + .unwrap_or(ContractStats { + total_shipments: 0, + total_volume: 0, + total_disputes: 0, + completed_shipments: 0, + }); + + CreateShipmentCtx { + max_value, + allowed_tokens, + min_pct, + contract_stats, + } + } + + /// Fetch ConfirmMilestoneCtx: consolidates core storage reads for confirm_milestone. + /// Keys accessed: Shipment, ContractStats. + fn fetch_confirm_milestone_ctx(env: &Env, shipment_id: &String) -> ConfirmMilestoneCtx { + let shipment = Self::get_shipment_internal(env, shipment_id); + let contract_stats: ContractStats = env + .storage() + .instance() + .get(&DataKey::ContractStats) + .unwrap_or(ContractStats { + total_shipments: 0, + total_volume: 0, + total_disputes: 0, + completed_shipments: 0, + }); + + ConfirmMilestoneCtx { + shipment, + contract_stats, + } + } + + /// Fetch ResolveDisputeCtx: consolidates dispute resolution storage reads. + /// Keys accessed: Shipment, DisputeContestedPercent, ContractStats, ActiveDisputes. + fn fetch_resolve_dispute_ctx( + env: &Env, + shipment_id: &String, + milestone_index: u32, + ) -> ResolveDisputeCtx { + let shipment = Self::get_shipment_internal(env, shipment_id); + let contested_key = DataKey::DisputeContestedPercent(shipment_id.clone(), milestone_index); + let partial_contested_percent: Option = + env.storage().persistent().get(&contested_key); + let contract_stats: ContractStats = env + .storage() + .instance() + .get(&DataKey::ContractStats) + .unwrap_or(ContractStats { + total_shipments: 0, + total_volume: 0, + total_disputes: 0, + completed_shipments: 0, + }); + let active_disputes: Vec = env + .storage() + .persistent() + .get(&DataKey::ActiveDisputes) + .unwrap_or_else(|| Vec::new(env)); + + ResolveDisputeCtx { + shipment, + partial_contested_percent, + contract_stats, + active_disputes, + } + } + fn get_shipment_internal(env: &Env, shipment_id: &String) -> Shipment { env.storage() .persistent() @@ -4194,3 +4295,4 @@ mod test_upgrade; mod test_concurrent_disputes; mod test_boundaries; mod test_chaos; +mod test_regression; diff --git a/contracts/chainsetttle/src/storage.rs b/contracts/chainsetttle/src/storage.rs index 19ba957..7926da6 100644 --- a/contracts/chainsetttle/src/storage.rs +++ b/contracts/chainsetttle/src/storage.rs @@ -2,7 +2,7 @@ use soroban_sdk::{contracttype, Address, Env, String, Vec}; use crate::{ constants::{TTL_INITIAL_LEDGERS, TTL_MAX_LEDGERS}, - AuditEntry, CancelPolicy, ContractStats, DisputeEntry, FeeConfig, MultiAdminConfig, Shipment, + AuditEntry, CancelPolicy, ContractStats, DisputeEntry, FeeConfig, MultiAdminConfig, ReputationScore, Shipment, ShipmentStatus, }; diff --git a/contracts/chainsetttle/src/test.rs b/contracts/chainsetttle/src/test.rs index a465dbc..288da59 100644 --- a/contracts/chainsetttle/src/test.rs +++ b/contracts/chainsetttle/src/test.rs @@ -1,4652 +1,17 @@ #![cfg(test)] -extern crate std; - -use super::*; -use soroban_sdk::{ - testutils::{Address as _, Ledger as _}, - token, vec, Address, BytesN, Env, String, Symbol, -}; -use std::format; - -// ============================================================ -// TEST HELPERS -// ============================================================ - -struct TestSetup { - env: Env, - contract_id: Address, - token_id: Address, - buyer: Address, - buyer2: Address, - supplier: Address, - logistics: Address, - arbiter: Address, - treasury: Address, -} - -/// Create a mock SAC (Stellar Asset Contract) for testing. -/// Returns the token address and mints initial funds to addresses. -fn mock_token(env: &Env, admin: &Address, addresses: &Vec
, initial_balance: i128) -> Address { - let token_id = env - .register_stellar_asset_contract_v2(admin.clone()) - .address(); - let token_client = token::StellarAssetClient::new(env, &token_id); - - for i in 0..addresses.len() { - token_client.mint(addresses.get(i).unwrap(), &initial_balance); - } - - token_id -} - -fn setup() -> TestSetup { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(ChainSettleContract, ()); - - let buyer = Address::generate(&env); - let buyer2 = Address::generate(&env); - let supplier = Address::generate(&env); - let logistics = Address::generate(&env); - let arbiter = Address::generate(&env); - let treasury = Address::generate(&env); - let token_admin = Address::generate(&env); - - let addresses = vec![&env, buyer.clone(), buyer2.clone()]; - let token_id = mock_token(&env, &token_admin, &addresses, 10_000_000_000); - - let client = ChainSettleContractClient::new(&env, &contract_id); - client.init(&buyer); - - TestSetup { - env, - contract_id, - token_id, - buyer, - buyer2, - supplier, - logistics, - arbiter, - treasury, - } -} - -fn build_milestones(env: &Env) -> soroban_sdk::Vec { - vec![ - env, - Milestone { - name: String::from_str(env, "Goods Dispatched"), - payment_percent: 25, - proof_hash: String::from_str(env, ""), - status: MilestoneStatus::Pending, - release_after_ledger: 0, - proof_submitted_ledger: None, - dispute_opened_ledger: None, - }, - Milestone { - name: String::from_str(env, "In Transit"), - payment_percent: 50, - proof_hash: String::from_str(env, ""), - status: MilestoneStatus::Pending, - release_after_ledger: 0, - proof_submitted_ledger: None, - dispute_opened_ledger: None, - }, - Milestone { - name: String::from_str(env, "Delivered"), - payment_percent: 25, - proof_hash: String::from_str(env, ""), - status: MilestoneStatus::Pending, - release_after_ledger: 0, - proof_submitted_ledger: None, - dispute_opened_ledger: None, - }, - ] -} - -fn single_buyer_vec(env: &Env, buyer: &Address) -> soroban_sdk::Vec
{ - vec![env, buyer.clone()] -} - -fn default_options(_env: &Env) -> ShipmentOptions { - ShipmentOptions { - response_deadline: 0, - penalty_bps: 0, - milestone_mode: MilestoneMode::Parallel, - holdback_ledgers: 0, - dispute_cooldown_ledgers: 0, - late_penalty_bps_per_ledger: 0, - auto_confirm_ledgers: 0, - dispute_bond_amount: 0, - arbiter_fee_bps: 0, - logistics_fee_bps: 0, - supplier_collateral: 0, - expires_at_ledger: None, - } -} - -/// Create a standard shipment with no deadline, no penalty, parallel mode, no holdback, no cooldown. -fn create_standard_shipment( - client: &ChainSettleContractClient, - env: &Env, - shipment_id: &String, - buyer: &Address, - supplier: &Address, - logistics: &Address, - arbiter: &Address, - token_id: &Address, - total_amount: i128, -) { - client.create_shipment( - shipment_id, - &single_buyer_vec(env, buyer), - supplier, - logistics, - arbiter, - token_id, - &total_amount, - &build_milestones(env), - &default_options(env), - ); -} - -// ============================================================ -// CORE LIFECYCLE TESTS -// ============================================================ - -#[test] -fn test_create_shipment_success() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "SHIP-001"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - assert_eq!( - token_client.balance(&t.buyer), - 10_000_000_000 - total_amount - ); - assert_eq!(token_client.balance(&t.contract_id), total_amount); - - let shipment = client.get_shipment(&shipment_id); - assert_eq!(shipment.status, ShipmentStatus::Active); - assert_eq!(shipment.total_amount, total_amount); - assert_eq!(shipment.released_amount, 0); - assert_eq!(shipment.milestones.len(), 3); - assert_eq!(shipment.holdback_ledgers, 0); - assert_eq!(shipment.dispute_cooldown_ledgers, 0); -} - -#[test] -#[should_panic(expected = "milestone percentages must sum to 100")] -fn test_create_shipment_invalid_percentages() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let bad_milestones = vec![ - &t.env, - Milestone { - name: String::from_str(&t.env, "Step 1"), - payment_percent: 30, - proof_hash: String::from_str(&t.env, ""), - status: MilestoneStatus::Pending, - release_after_ledger: 0, - proof_submitted_ledger: None, - dispute_opened_ledger: None, - }, - Milestone { - name: String::from_str(&t.env, "Step 2"), - payment_percent: 30, - proof_hash: String::from_str(&t.env, ""), - status: MilestoneStatus::Pending, - release_after_ledger: 0, - proof_submitted_ledger: None, - dispute_opened_ledger: None, - }, - Milestone { - name: String::from_str(&t.env, "Step 3"), - payment_percent: 30, - proof_hash: String::from_str(&t.env, ""), - status: MilestoneStatus::Pending, - release_after_ledger: 0, - proof_submitted_ledger: None, - dispute_opened_ledger: None, - }, - ]; - - client.create_shipment( - &String::from_str(&t.env, "SHIP-BAD"), - &single_buyer_vec(&t.env, &t.buyer), - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &1_000_000_000, - &bad_milestones, - &default_options(&t.env), - ); -} - -#[test] -fn test_full_shipment_lifecycle() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "SHIP-FULL"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - client.submit_proof( - &t.logistics, - &shipment_id, - &1, - &String::from_str(&t.env, "ipfs://t"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &1); - - client.submit_proof( - &t.supplier, - &shipment_id, - &2, - &String::from_str(&t.env, "ipfs://v"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &2); - - let shipment = client.get_shipment(&shipment_id); - assert_eq!(shipment.status, ShipmentStatus::Completed); - assert_eq!(shipment.released_amount, total_amount); - assert_eq!(token_client.balance(&t.supplier), total_amount); - assert_eq!(client.get_escrow_balance(&shipment_id), 0); -} - -#[test] -fn test_full_lifecycle_with_dispute() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "SHIP-FULL-DISP"); - let total_amount: i128 = 1_000_000_000; - - let buyer_balance_before = token_client.balance(&t.buyer); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - // Milestone 0: supplier submits proof and buyer confirms -> payment released - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d0"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - let m0_payment = total_amount * 25 / 100; - assert_eq!(token_client.balance(&t.supplier), m0_payment); - assert_eq!( - client.get_escrow_balance(&shipment_id), - total_amount - m0_payment - ); - - // Milestone 1: submit -> buyer disputes -> arbiter rejects -> supplier resubmits -> buyer confirms - client.submit_proof( - &t.supplier, - &shipment_id, - &1, - &String::from_str(&t.env, "ipfs://d1"), - - &Symbol::new(&t.env, "ipfs"),); - client.raise_dispute(&t.buyer, &shipment_id, &1); - client.resolve_dispute(&t.arbiter, &shipment_id, &1, &false); - - // After reject, supplier resubmits proof and buyer confirms - client.submit_proof( - &t.supplier, - &shipment_id, - &1, - &String::from_str(&t.env, "ipfs://d1-resub"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &1); - - let m1_payment = total_amount * 50 / 100; - assert_eq!(token_client.balance(&t.supplier), m0_payment + m1_payment); - assert_eq!( - client.get_escrow_balance(&shipment_id), - total_amount - m0_payment - m1_payment - ); - - // Milestone 2: submit and confirm -> final payment and completion - client.submit_proof( - &t.supplier, - &shipment_id, - &2, - &String::from_str(&t.env, "ipfs://d2"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &2); - - let shipment = client.get_shipment(&shipment_id); - assert_eq!(shipment.status, ShipmentStatus::Completed); - assert_eq!(shipment.released_amount, total_amount); - - // Final balances: supplier gets full amount, buyer reduced by total_amount, contract escrow zero - assert_eq!(token_client.balance(&t.supplier), total_amount); - assert_eq!( - token_client.balance(&t.buyer), - buyer_balance_before - total_amount - ); - assert_eq!(client.get_escrow_balance(&shipment_id), 0); -} - -#[test] -fn test_cancel_shipment() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "SHIP-CANCEL"); - let total_amount: i128 = 1_000_000_000; - let buyer_balance_before = token_client.balance(&t.buyer); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - client.cancel_shipment(&t.buyer, &shipment_id); - - let shipment = client.get_shipment(&shipment_id); - assert_eq!(shipment.status, ShipmentStatus::Cancelled); - assert_eq!(token_client.balance(&t.buyer), buyer_balance_before); -} - -#[test] -fn test_cancel_partial_confirmed() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "SHIP-PARTIAL"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - // Confirm milestone 0 (25%) - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - let buyer_balance_after_confirm = token_client.balance(&t.buyer); - client.cancel_shipment(&t.buyer, &shipment_id); - - // Buyer should get back 75% (the unconfirmed portion) - let expected_refund = total_amount * 75 / 100; - assert_eq!( - token_client.balance(&t.buyer), - buyer_balance_after_confirm + expected_refund - ); - assert_eq!( - client.get_shipment(&shipment_id).status, - ShipmentStatus::Cancelled - ); -} - -#[test] -#[should_panic(expected = "cannot cancel: dispute must be resolved first")] -fn test_cancel_blocked_by_dispute() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-DISP-CANCEL"); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.raise_dispute(&t.buyer, &shipment_id, &0); - client.cancel_shipment(&t.buyer, &shipment_id); -} - -#[test] -fn test_cancel_zero_confirmed() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let total: i128 = 1_000_000_000; - let before = token_client.balance(&t.buyer); - let shipment_id = String::from_str(&t.env, "CANCEL-ZERO"); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total, - ); - - client.cancel_shipment(&t.buyer, &shipment_id); - - assert_eq!( - client.get_shipment(&shipment_id).status, - ShipmentStatus::Cancelled - ); - assert_eq!(token_client.balance(&t.buyer), before); -} - -// ============================================================ -// PAUSE / UNPAUSE TESTS -// ============================================================ - -#[test] -fn test_pause_blocks_state_changes() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - // Pause the contract (buyer is admin in setup). - client.pause(&t.buyer); - assert!(client.is_paused()); - // The should_panic tests below verify individual functions are blocked. -} - -#[test] -#[should_panic(expected = "contract is paused")] -fn test_pause_blocks_create_shipment() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - client.pause(&t.buyer); - - create_standard_shipment( - &client, - &t.env, - &String::from_str(&t.env, "SHIP-PAUSED"), - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); -} - -#[test] -fn test_unpause_restores_operations() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - client.pause(&t.buyer); - assert!(client.is_paused()); - - client.unpause(&t.buyer); - assert!(!client.is_paused()); - - // Should succeed after unpause. - let shipment_id = String::from_str(&t.env, "SHIP-UNPAUSED"); - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - assert_eq!( - client.get_shipment(&shipment_id).status, - ShipmentStatus::Active - ); -} - -#[test] -#[should_panic(expected = "contract is paused")] -fn test_pause_blocks_confirm_milestone() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-PAUSE-CONF"); - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - - client.pause(&t.buyer); - client.confirm_milestone(&t.buyer, &shipment_id, &0); -} - -#[test] -fn test_read_only_accessible_while_paused() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-READ-PAUSED"); - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - client.pause(&t.buyer); - - // Read-only calls must still work. - let shipment = client.get_shipment(&shipment_id); - assert_eq!(shipment.status, ShipmentStatus::Active); - - let milestone = client.get_milestone(&shipment_id, &0); - assert_eq!(milestone.status, MilestoneStatus::Pending); - - let balance = client.get_escrow_balance(&shipment_id); - assert_eq!(balance, 1_000_000_000); -} - -#[test] -#[should_panic(expected = "unauthorized")] -fn test_non_admin_cannot_pause() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - // supplier is not admin - client.pause(&t.supplier); -} - -// ============================================================ -// ESCROW TOP-UP TESTS -// ============================================================ - -#[test] -fn test_top_up_escrow_increases_total_amount() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "SHIP-TOPUP"); - let initial_amount: i128 = 1_000_000_000; - let top_up: i128 = 500_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - initial_amount, - ); - - client.top_up_escrow(&t.buyer, &shipment_id, &top_up); - - let shipment = client.get_shipment(&shipment_id); - assert_eq!(shipment.total_amount, initial_amount + top_up); - assert_eq!( - client.get_escrow_balance(&shipment_id), - initial_amount + top_up - ); - assert_eq!( - token_client.balance(&t.contract_id), - initial_amount + top_up - ); -} - -#[test] -fn test_top_up_proportionally_increases_milestone_payments() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "SHIP-TOPUP-PROP"); - let initial_amount: i128 = 1_000_000_000; - let top_up: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - initial_amount, - ); - - client.top_up_escrow(&t.buyer, &shipment_id, &top_up); - - // Confirm milestone 0 (25%) — payment should be 25% of new total (2_000_000_000) - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - let expected_payment = (initial_amount + top_up) * 25 / 100; // 500_000_000 - assert_eq!(token_client.balance(&t.supplier), expected_payment); -} - -#[test] -#[should_panic(expected = "top-up disallowed: shipment is not active")] -fn test_top_up_disallowed_after_completion() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-TOPUP-DONE"); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - // Complete the shipment. - for i in 0u32..3u32 { - client.submit_proof( - &t.supplier, - &shipment_id, - &i, - &String::from_str(&t.env, "ipfs://x"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &i); - } - - assert_eq!( - client.get_shipment(&shipment_id).status, - ShipmentStatus::Completed - ); - client.top_up_escrow(&t.buyer, &shipment_id, &100_000); -} - -#[test] -#[should_panic(expected = "top-up disallowed: shipment is not active")] -fn test_top_up_disallowed_after_cancellation() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-TOPUP-CANCEL"); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - client.cancel_shipment(&t.buyer, &shipment_id); - client.top_up_escrow(&t.buyer, &shipment_id, &100_000); -} - -#[test] -fn test_min_milestone_percent_accepts_threshold() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - client.set_min_milestone_percent(&t.buyer, &5); - - let shipment_id = String::from_str(&t.env, "SHIP-MIN-OK"); - let milestones = vec![ - &t.env, - Milestone { - name: String::from_str(&t.env, "Goods Dispatched"), - payment_percent: 5, - proof_hash: String::from_str(&t.env, ""), - status: MilestoneStatus::Pending, - release_after_ledger: 0, - proof_submitted_ledger: None, - dispute_opened_ledger: None, - }, - Milestone { - name: String::from_str(&t.env, "In Transit"), - payment_percent: 45, - proof_hash: String::from_str(&t.env, ""), - status: MilestoneStatus::Pending, - release_after_ledger: 0, - proof_submitted_ledger: None, - dispute_opened_ledger: None, - }, - Milestone { - name: String::from_str(&t.env, "Delivered"), - payment_percent: 50, - proof_hash: String::from_str(&t.env, ""), - status: MilestoneStatus::Pending, - release_after_ledger: 0, - proof_submitted_ledger: None, - dispute_opened_ledger: None, - }, - ]; - - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &1_000_000_000, - &milestones, - &default_options(&t.env), - ); - - assert_eq!( - client.get_shipment(&shipment_id).status, - ShipmentStatus::Active - ); -} - -#[test] -#[should_panic(expected = "InvalidPercentages")] -fn test_min_milestone_percent_rejects_below_threshold() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-MIN-BAD"); - let milestones = vec![ - &t.env, - Milestone { - name: String::from_str(&t.env, "Goods Dispatched"), - payment_percent: 4, - proof_hash: String::from_str(&t.env, ""), - status: MilestoneStatus::Pending, - release_after_ledger: 0, - proof_submitted_ledger: None, - dispute_opened_ledger: None, - }, - Milestone { - name: String::from_str(&t.env, "In Transit"), - payment_percent: 46, - proof_hash: String::from_str(&t.env, ""), - status: MilestoneStatus::Pending, - release_after_ledger: 0, - proof_submitted_ledger: None, - dispute_opened_ledger: None, - }, - Milestone { - name: String::from_str(&t.env, "Delivered"), - payment_percent: 50, - proof_hash: String::from_str(&t.env, ""), - status: MilestoneStatus::Pending, - release_after_ledger: 0, - proof_submitted_ledger: None, - dispute_opened_ledger: None, - }, - ]; - - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &1_000_000_000, - &milestones, - &default_options(&t.env), - ); -} - -#[test] -fn test_min_milestone_percent_updates_via_admin() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - client.set_min_milestone_percent(&t.buyer, &10); - assert_eq!(client.get_admin_log().len() >= 1, true); - - let shipment_id = String::from_str(&t.env, "SHIP-MIN-UPDATE"); - let milestones = vec![ - &t.env, - Milestone { - name: String::from_str(&t.env, "Goods Dispatched"), - payment_percent: 10, - proof_hash: String::from_str(&t.env, ""), - status: MilestoneStatus::Pending, - release_after_ledger: 0, - proof_submitted_ledger: None, - dispute_opened_ledger: None, - }, - Milestone { - name: String::from_str(&t.env, "In Transit"), - payment_percent: 40, - proof_hash: String::from_str(&t.env, ""), - status: MilestoneStatus::Pending, - release_after_ledger: 0, - proof_submitted_ledger: None, - dispute_opened_ledger: None, - }, - Milestone { - name: String::from_str(&t.env, "Delivered"), - payment_percent: 50, - proof_hash: String::from_str(&t.env, ""), - status: MilestoneStatus::Pending, - release_after_ledger: 0, - proof_submitted_ledger: None, - dispute_opened_ledger: None, - }, - ]; - - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &1_000_000_000, - &milestones, - &default_options(&t.env), - ); - assert_eq!( - client.get_shipment(&shipment_id).status, - ShipmentStatus::Active - ); -} - -#[test] -#[should_panic(expected = "unauthorized")] -fn test_non_admin_cannot_update_min_milestone_percent() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - client.set_min_milestone_percent(&t.supplier, &7); -} - -#[test] -#[should_panic(expected = "unauthorized")] -fn test_blacklisted_buyer_blocks_create_shipment() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let reason = BytesN::from_array(&t.env, &[0u8; 32]); - client.blacklist_address(&t.buyer, &t.buyer, &reason); - - let shipment_id = String::from_str(&t.env, "SHIP-BLACK-BUYER"); - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &1_000_000_000, - &build_milestones(&t.env), - &default_options(&t.env), - ); -} - -#[test] -fn test_blacklist_removal_restores_participation() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let reason = BytesN::from_array(&t.env, &[1u8; 32]); - client.blacklist_address(&t.buyer, &t.supplier, &reason); - assert!(client.is_blacklisted(&t.supplier)); - - client.remove_from_blacklist(&t.buyer, &t.supplier); - assert!(!client.is_blacklisted(&t.supplier)); - - let shipment_id = String::from_str(&t.env, "SHIP-BLACK-RESTORED"); - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - assert_eq!( - client.get_shipment(&shipment_id).status, - ShipmentStatus::Active - ); -} - -#[test] -#[should_panic(expected = "unauthorized")] -fn test_blacklisted_arbiter_blocks_create_shipment() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let reason = BytesN::from_array(&t.env, &[2u8; 32]); - client.blacklist_address(&t.buyer, &t.arbiter, &reason); - - let shipment_id = String::from_str(&t.env, "SHIP-BLACK-ARBITER"); - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &1_000_000_000, - &build_milestones(&t.env), - &default_options(&t.env), - ); -} - -#[test] -fn test_existing_shipment_works_after_post_creation_blacklist() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let shipment_id = String::from_str(&t.env, "SHIP-BLACK-EXISTING"); - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - let reason = BytesN::from_array(&t.env, &[3u8; 32]); - client.blacklist_address(&t.buyer, &t.supplier, &reason); - - assert_eq!( - client.get_shipment(&shipment_id).status, - ShipmentStatus::Active - ); - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - assert_eq!( - client.get_milestone(&shipment_id, &0).status, - MilestoneStatus::ProofSubmitted - ); -} - -#[test] -#[should_panic(expected = "DisputeAlreadyOpen")] -fn test_dispute_limit_blocks_second() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let shipment_id = String::from_str(&t.env, "SHIP-DISP-LIMIT"); - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.submit_proof( - &t.supplier, - &shipment_id, - &1, - &String::from_str(&t.env, "ipfs://t"), - - &Symbol::new(&t.env, "ipfs"),); - client.raise_dispute(&t.buyer, &shipment_id, &0); - assert_eq!(client.get_shipment(&shipment_id).open_dispute_count, 1); - client.raise_dispute(&t.buyer, &shipment_id, &1); -} - -#[test] -fn test_dispute_limit_frees_slot_on_resolution() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let shipment_id = String::from_str(&t.env, "SHIP-DISP-RESOLVE"); - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.raise_dispute(&t.buyer, &shipment_id, &0); - assert_eq!(client.get_shipment(&shipment_id).open_dispute_count, 1); - client.resolve_dispute(&t.arbiter, &shipment_id, &0, &false); - assert_eq!(client.get_shipment(&shipment_id).open_dispute_count, 0); -} - -#[test] -fn test_dispute_limit_two_allows_two_concurrent_disputes() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - client.set_max_concurrent_disputes(&t.buyer, &2); - - let shipment_id = String::from_str(&t.env, "SHIP-DISP-2"); - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.submit_proof( - &t.supplier, - &shipment_id, - &1, - &String::from_str(&t.env, "ipfs://t"), - - &Symbol::new(&t.env, "ipfs"),); - - client.raise_dispute(&t.buyer, &shipment_id, &0); - client.raise_dispute(&t.buyer, &shipment_id, &1); - assert_eq!(client.get_shipment(&shipment_id).open_dispute_count, 2); -} - -#[test] -fn test_admin_action_log_capped_and_ordered() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - for i in 1..=51 { - let pct = if i <= 100 { i as u32 } else { 100 }; - t.env.ledger().with_mut(|l| l.sequence_number += 1); - client.set_min_milestone_percent(&t.buyer, &pct); - } - - let log = client.get_admin_log(); - assert_eq!(log.len(), 50); - for i in 1..log.len() { - assert!(log.get(i - 1).unwrap().ledger <= log.get(i).unwrap().ledger); - } -} - -#[test] -#[should_panic(expected = "unauthorized")] -fn test_top_up_non_buyer_rejected() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-TOPUP-AUTH"); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - // Supplier is not a buyer — must be rejected. - client.top_up_escrow(&t.supplier, &shipment_id, &100_000); -} - -// ============================================================ -// DISPUTE COOLDOWN TESTS -// ============================================================ - -#[test] -fn test_dispute_cooldown_enforced() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-COOLDOWN"); - let cooldown: u32 = 100; - - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &1_000_000_000, - &build_milestones(&t.env), - &ShipmentOptions { - response_deadline: 0, - penalty_bps: 0, - milestone_mode: MilestoneMode::Parallel, - holdback_ledgers: 0, - dispute_cooldown_ledgers: cooldown, - late_penalty_bps_per_ledger: 0, - auto_confirm_ledgers: 0, - dispute_bond_amount: 0, - arbiter_fee_bps: 0, - logistics_fee_bps: 0, - supplier_collateral: 0, - expires_at_ledger: None, - }, - ); - - // First dispute on milestone 0. - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.raise_dispute(&t.buyer, &shipment_id, &0); - // Arbiter rejects — milestone goes back to Pending, cooldown starts. - client.resolve_dispute(&t.arbiter, &shipment_id, &0, &false); - - // Resubmit proof for milestone 0. - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d2"), - - &Symbol::new(&t.env, "ipfs"),); - - // Immediately trying to raise another dispute should fail (cooldown not elapsed). - // We test this in the should_panic test below. - - // Advance ledger past cooldown. - t.env.ledger().set_sequence_number(cooldown + 1); - - // Now dispute should succeed. - client.raise_dispute(&t.buyer, &shipment_id, &0); - assert_eq!( - client.get_milestone(&shipment_id, &0).status, - MilestoneStatus::Disputed - ); -} - -#[test] -#[should_panic(expected = "dispute cooldown period has not elapsed")] -fn test_dispute_cooldown_blocks_early_redispute() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-COOLDOWN-BLOCK"); - let cooldown: u32 = 500; - - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &1_000_000_000, - &build_milestones(&t.env), - &ShipmentOptions { - response_deadline: 0, - penalty_bps: 0, - milestone_mode: MilestoneMode::Parallel, - holdback_ledgers: 0, - dispute_cooldown_ledgers: cooldown, - late_penalty_bps_per_ledger: 0, - auto_confirm_ledgers: 0, - dispute_bond_amount: 0, - arbiter_fee_bps: 0, - logistics_fee_bps: 0, - supplier_collateral: 0, - expires_at_ledger: None, - }, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.raise_dispute(&t.buyer, &shipment_id, &0); - client.resolve_dispute(&t.arbiter, &shipment_id, &0, &false); - - // Resubmit and immediately try to dispute again — must panic. - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d2"), - - &Symbol::new(&t.env, "ipfs"),); - client.raise_dispute(&t.buyer, &shipment_id, &0); -} - -#[test] -fn test_no_cooldown_allows_immediate_redispute() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-NO-COOLDOWN"); - - // cooldown = 0 means no restriction. - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.raise_dispute(&t.buyer, &shipment_id, &0); - client.resolve_dispute(&t.arbiter, &shipment_id, &0, &false); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d2"), - - &Symbol::new(&t.env, "ipfs"),); - // Should succeed immediately with no cooldown. - client.raise_dispute(&t.buyer, &shipment_id, &0); - assert_eq!( - client.get_milestone(&shipment_id, &0).status, - MilestoneStatus::Disputed - ); -} - -#[test] -fn test_cooldown_updated_on_resolve() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-COOLDOWN-UPD"); - let cooldown: u32 = 50; - - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &1_000_000_000, - &build_milestones(&t.env), - &ShipmentOptions { - response_deadline: 0, - penalty_bps: 0, - milestone_mode: MilestoneMode::Parallel, - holdback_ledgers: 0, - dispute_cooldown_ledgers: cooldown, - late_penalty_bps_per_ledger: 0, - auto_confirm_ledgers: 0, - dispute_bond_amount: 0, - arbiter_fee_bps: 0, - logistics_fee_bps: 0, - supplier_collateral: 0, - expires_at_ledger: None, - }, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.raise_dispute(&t.buyer, &shipment_id, &0); - - t.env.ledger().set_sequence_number(10); - client.resolve_dispute(&t.arbiter, &shipment_id, &0, &false); - - // last_dispute_resolved_ledger should now be 10. - let shipment = client.get_shipment(&shipment_id); - assert_eq!(shipment.last_dispute_resolved_ledger, Some(10u32)); -} - -// ============================================================ -// TRANSFER BUYER / SUPPLIER TESTS -// ============================================================ - -#[test] -fn test_transfer_buyer_success() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-XFER-BUYER"); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - client.transfer_buyer(&t.buyer, &shipment_id, &t.buyer2); - - let shipment = client.get_shipment(&shipment_id); - // buyer2 should now be in the buyers list; original buyer should be gone. - assert_eq!(shipment.buyers.get(0).unwrap(), t.buyer2); - assert!(!shipment.buyers.iter().any(|b| b == t.buyer)); -} - -#[test] -fn test_transfer_buyer_new_buyer_can_confirm() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "SHIP-XFER-BUYER-CONF"); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - client.transfer_buyer(&t.buyer, &shipment_id, &t.buyer2); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - // New buyer confirms — should succeed. - client.confirm_milestone(&t.buyer2, &shipment_id, &0); - - assert_eq!( - client.get_milestone(&shipment_id, &0).status, - MilestoneStatus::Confirmed - ); - assert_eq!(token_client.balance(&t.supplier), 1_000_000_000 * 25 / 100); -} - -#[test] -#[should_panic(expected = "unauthorized")] -fn test_transfer_buyer_old_buyer_cannot_confirm_after_transfer() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-XFER-BUYER-OLD"); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - client.transfer_buyer(&t.buyer, &shipment_id, &t.buyer2); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - // Old buyer tries to confirm — must be rejected. - client.confirm_milestone(&t.buyer, &shipment_id, &0); -} - -#[test] -#[should_panic(expected = "transfer disallowed: open dispute must be resolved first")] -fn test_transfer_buyer_blocked_by_dispute() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-XFER-DISP"); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.raise_dispute(&t.buyer, &shipment_id, &0); - - // Transfer while dispute is open — must panic. - client.transfer_buyer(&t.buyer, &shipment_id, &t.buyer2); -} - -#[test] -fn test_transfer_supplier_success() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let new_supplier = Address::generate(&t.env); - let shipment_id = String::from_str(&t.env, "SHIP-XFER-SUP"); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - client.transfer_supplier(&t.supplier, &shipment_id, &new_supplier); - - let shipment = client.get_shipment(&shipment_id); - assert_eq!(shipment.supplier, new_supplier); - - // Payment should go to new_supplier after confirmation. - client.submit_proof( - &new_supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - assert_eq!( - token_client.balance(&new_supplier), - 1_000_000_000 * 25 / 100 - ); - assert_eq!(token_client.balance(&t.supplier), 0); -} - -#[test] -#[should_panic(expected = "unauthorized")] -fn test_transfer_supplier_wrong_caller_rejected() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let new_supplier = Address::generate(&t.env); - let shipment_id = String::from_str(&t.env, "SHIP-XFER-SUP-AUTH"); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - // Buyer tries to transfer supplier role — must be rejected. - client.transfer_supplier(&t.buyer, &shipment_id, &new_supplier); -} - -#[test] -#[should_panic(expected = "transfer disallowed: open dispute must be resolved first")] -fn test_transfer_supplier_blocked_by_dispute() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let new_supplier = Address::generate(&t.env); - let shipment_id = String::from_str(&t.env, "SHIP-XFER-SUP-DISP"); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.raise_dispute(&t.buyer, &shipment_id, &0); - - client.transfer_supplier(&t.supplier, &shipment_id, &new_supplier); -} - -// ============================================================ -// NON-SEQUENTIAL / PARALLEL MODE TESTS -// ============================================================ - -#[test] -fn test_non_sequential_baseline() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "NONSEQ"); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - // In parallel mode, milestone 2 can be submitted before milestone 0. - client.submit_proof( - &t.supplier, - &shipment_id, - &2, - &String::from_str(&t.env, "ipfs://v"), - - &Symbol::new(&t.env, "ipfs"),); - assert_eq!( - client.get_milestone(&shipment_id, &2).status, - MilestoneStatus::ProofSubmitted - ); -} - -#[test] -fn test_parallel_mode_allows_any_order() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "PARALLEL"); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - // Submit and confirm in reverse order. - client.submit_proof( - &t.supplier, - &shipment_id, - &2, - &String::from_str(&t.env, "ipfs://v"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &2); - - client.submit_proof( - &t.supplier, - &shipment_id, - &1, - &String::from_str(&t.env, "ipfs://t"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &1); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - assert_eq!( - client.get_shipment(&shipment_id).status, - ShipmentStatus::Completed - ); - assert_eq!(token_client.balance(&t.supplier), 1_000_000_000); -} - -// ============================================================ -// TOKEN WHITELIST TESTS -// ============================================================ - -#[test] -#[should_panic(expected = "unauthorized")] -fn test_non_whitelisted_token_rejected() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - // Whitelist only token_id; try to use a different token. - client.add_allowed_token(&t.token_id); - - let other_admin = Address::generate(&t.env); - let other_token = t - .env - .register_stellar_asset_contract_v2(other_admin.clone()) - .address(); - token::StellarAssetClient::new(&t.env, &other_token).mint(&t.buyer, &10_000_000_000); - - client.create_shipment( - &String::from_str(&t.env, "BAD-TOKEN"), - &single_buyer_vec(&t.env, &t.buyer), - &t.supplier, - &t.logistics, - &t.arbiter, - &other_token, - &1_000_000_000, - &build_milestones(&t.env), - &ShipmentOptions { - response_deadline: 0, - penalty_bps: 0, - milestone_mode: MilestoneMode::Parallel, - holdback_ledgers: 0, - dispute_cooldown_ledgers: 0, - late_penalty_bps_per_ledger: 0, - auto_confirm_ledgers: 0, - dispute_bond_amount: 0, - arbiter_fee_bps: 0, - logistics_fee_bps: 0, - supplier_collateral: 0, - expires_at_ledger: None, - }, - ); -} - -// ============================================================ -// FEE TESTS -// ============================================================ - -#[test] -fn test_fee_deducted_on_confirm_milestone() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let fee_bps: u32 = 100; // 1% - client.set_fee_config(&t.buyer, &fee_bps, &t.treasury); - - let shipment_id = String::from_str(&t.env, "SHIP-FEE"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - let gross = total_amount * 25 / 100; // 250_000_000 - let fee = gross * fee_bps as i128 / 10_000; // 2_500_000 - let net = gross - fee; - - assert_eq!(token_client.balance(&t.supplier), net); - assert_eq!(token_client.balance(&t.treasury), fee); -} - -#[test] -#[should_panic(expected = "fee_bps exceeds maximum of 1000")] -fn test_fee_bps_exceeds_max_rejected() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - client.set_fee_config(&t.buyer, &1001, &t.treasury); -} - -#[test] -fn test_no_fee_config_backward_compatible() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "SHIP-NOFEE"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - // No fee config — supplier gets full gross payment. - assert_eq!(token_client.balance(&t.supplier), total_amount * 25 / 100); -} - -// ============================================================ -// HOLDBACK TESTS -// ============================================================ - -#[test] -fn test_holdback_happy_path() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "SHIP-HOLD"); - let holdback: u32 = 100; - - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &1_000_000_000, - &build_milestones(&t.env), - &ShipmentOptions { - response_deadline: 0, - penalty_bps: 0, - milestone_mode: MilestoneMode::Parallel, - holdback_ledgers: holdback, - dispute_cooldown_ledgers: 0, - late_penalty_bps_per_ledger: 0, - auto_confirm_ledgers: 0, - dispute_bond_amount: 0, - arbiter_fee_bps: 0, - logistics_fee_bps: 0, - supplier_collateral: 0, - expires_at_ledger: None, - }, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - // Payment should be held — supplier balance still 0. - assert_eq!(token_client.balance(&t.supplier), 0); - assert_eq!( - client.get_milestone(&shipment_id, &0).status, - MilestoneStatus::ConfirmedHeld - ); - - // Advance past holdback window. - t.env.ledger().set_sequence_number(holdback + 1); - client.release_held_payment(&shipment_id, &0); - - assert_eq!( - client.get_milestone(&shipment_id, &0).status, - MilestoneStatus::Confirmed - ); - assert_eq!(token_client.balance(&t.supplier), 1_000_000_000 * 25 / 100); -} - -#[test] -fn test_no_holdback_immediate_transfer() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "SHIP-NOHOLD"); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - assert_eq!(token_client.balance(&t.supplier), 1_000_000_000 * 25 / 100); -} - -#[test] -fn test_holdback_early_dispute_cancels_hold() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-HOLD-DISP"); - - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &1_000_000_000, - &build_milestones(&t.env), - &ShipmentOptions { - response_deadline: 0, - penalty_bps: 0, - milestone_mode: MilestoneMode::Parallel, - holdback_ledgers: 200, - dispute_cooldown_ledgers: 0, - late_penalty_bps_per_ledger: 0, - auto_confirm_ledgers: 0, - dispute_bond_amount: 0, - arbiter_fee_bps: 0, - logistics_fee_bps: 0, - supplier_collateral: 0, - expires_at_ledger: None, - }, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - assert_eq!( - client.get_milestone(&shipment_id, &0).status, - MilestoneStatus::ConfirmedHeld - ); - - // Buyer disputes the held milestone. - client.raise_dispute(&t.buyer, &shipment_id, &0); - - assert_eq!( - client.get_milestone(&shipment_id, &0).status, - MilestoneStatus::Disputed - ); - // release_after_ledger should be cleared. - assert_eq!( - client.get_milestone(&shipment_id, &0).release_after_ledger, - 0 - ); -} - -// ============================================================ -// BATCH CONFIRM TESTS -// ============================================================ - -#[test] -fn test_batch_confirm_milestones_full() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "SHIP-BATCH"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.submit_proof( - &t.logistics, - &shipment_id, - &1, - &String::from_str(&t.env, "ipfs://t"), - - &Symbol::new(&t.env, "ipfs"),); - client.submit_proof( - &t.supplier, - &shipment_id, - &2, - &String::from_str(&t.env, "ipfs://v"), - - &Symbol::new(&t.env, "ipfs"),); - - client.batch_confirm_milestones(&t.buyer, &shipment_id, &vec![&t.env, 0u32, 1u32, 2u32]); - - let shipment = client.get_shipment(&shipment_id); - assert_eq!(shipment.status, ShipmentStatus::Completed); - assert_eq!(shipment.released_amount, total_amount); - assert_eq!(token_client.balance(&t.supplier), total_amount); -} - -#[test] -fn test_batch_confirm_single_element() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "SHIP-BATCH-1"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.batch_confirm_milestones(&t.buyer, &shipment_id, &vec![&t.env, 0u32]); - - assert_eq!( - client.get_milestone(&shipment_id, &0).status, - MilestoneStatus::Confirmed - ); - assert_eq!(token_client.balance(&t.supplier), total_amount * 25 / 100); -} - -#[test] -fn test_batch_confirm_empty_is_noop() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-BATCH-EMPTY"); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - client.batch_confirm_milestones(&t.buyer, &shipment_id, &vec![&t.env]); - - let shipment = client.get_shipment(&shipment_id); - assert_eq!(shipment.status, ShipmentStatus::Active); - assert_eq!(shipment.released_amount, 0); -} - -#[test] -#[should_panic(expected = "milestone proof not yet submitted")] -fn test_batch_confirm_partial_invalid_reverts() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-BATCH-FAIL"); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - // Index 1 has no proof — must revert entirely. - client.batch_confirm_milestones(&t.buyer, &shipment_id, &vec![&t.env, 0u32, 1u32]); -} - -// ============================================================ -// MULTISIG TESTS -// ============================================================ - -#[test] -fn test_multisig_both_buyers_must_confirm() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "SHIP-MULTI"); - let total_amount: i128 = 1_000_000_000; - - // Two-buyer shipment. - client.create_shipment( - &shipment_id, - &vec![&t.env, t.buyer.clone(), t.buyer2.clone()], - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &total_amount, - &build_milestones(&t.env), - &ShipmentOptions { - response_deadline: 0, - penalty_bps: 0, - milestone_mode: MilestoneMode::Parallel, - holdback_ledgers: 0, - dispute_cooldown_ledgers: 0, - late_penalty_bps_per_ledger: 0, - auto_confirm_ledgers: 0, - dispute_bond_amount: 0, - arbiter_fee_bps: 0, - logistics_fee_bps: 0, - supplier_collateral: 0, - expires_at_ledger: None, - }, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - - // Either buyer can confirm independently in this implementation. - client.confirm_milestone(&t.buyer, &shipment_id, &0); - assert_eq!( - client.get_milestone(&shipment_id, &0).status, - MilestoneStatus::Confirmed - ); - assert_eq!(token_client.balance(&t.supplier), total_amount * 25 / 100); -} - -#[test] -#[should_panic(expected = "unauthorized")] -fn test_multisig_duplicate_approval_rejected() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-MULTI-DUP"); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - // Supplier is not a buyer — must be rejected. - client.confirm_milestone(&t.supplier, &shipment_id, &0); -} - -#[test] -fn test_multisig_minority_veto_dispute() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-MULTI-VETO"); - - client.create_shipment( - &shipment_id, - &vec![&t.env, t.buyer.clone(), t.buyer2.clone()], - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &1_000_000_000, - &build_milestones(&t.env), - &ShipmentOptions { - response_deadline: 0, - penalty_bps: 0, - milestone_mode: MilestoneMode::Parallel, - holdback_ledgers: 0, - dispute_cooldown_ledgers: 0, - late_penalty_bps_per_ledger: 0, - auto_confirm_ledgers: 0, - dispute_bond_amount: 0, - arbiter_fee_bps: 0, - logistics_fee_bps: 0, - supplier_collateral: 0, - expires_at_ledger: None, - }, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - // buyer2 raises a dispute — only one co-buyer needed. - client.raise_dispute(&t.buyer2, &shipment_id, &0); - - assert_eq!( - client.get_milestone(&shipment_id, &0).status, - MilestoneStatus::Disputed - ); -} - -// ============================================================ -// AMENDMENT TESTS -// ============================================================ - -#[test] -fn test_amendment_full_mutual_consent() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-AMEND"); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - let new_name = String::from_str(&t.env, "Goods Dispatched v2"); - - client.propose_amendment(&t.buyer, &shipment_id, &0, &25, &new_name); - // Not yet applied — only one party agreed. - assert_eq!( - client.get_milestone(&shipment_id, &0).name, - String::from_str(&t.env, "Goods Dispatched") - ); - - client.propose_amendment(&t.supplier, &shipment_id, &0, &25, &new_name); - // Both agreed — amendment applied. - assert_eq!(client.get_milestone(&shipment_id, &0).name, new_name); - assert_eq!(client.get_milestone(&shipment_id, &0).payment_percent, 25); -} - -#[test] -fn test_amendment_mismatched_proposals_no_op() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-MISMATCH"); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - client.propose_amendment( - &t.buyer, - &shipment_id, - &0, - &25, - &String::from_str(&t.env, "Name A"), - ); - client.propose_amendment( - &t.supplier, - &shipment_id, - &0, - &25, - &String::from_str(&t.env, "Name B"), - ); - - // Mismatch — milestone unchanged. - assert_eq!( - client.get_milestone(&shipment_id, &0).name, - String::from_str(&t.env, "Goods Dispatched") - ); -} - -#[test] -#[should_panic(expected = "can only amend a pending milestone")] -fn test_amendment_confirmed_milestone_rejected() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-AMEND-CONF"); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - client.propose_amendment( - &t.buyer, - &shipment_id, - &0, - &25, - &String::from_str(&t.env, "New Name"), - ); -} - -#[test] -#[should_panic(expected = "milestone percentages must sum to 100")] -fn test_amendment_invalid_percentage_sum() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-AMEND-PCT"); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - 1_000_000_000, - ); - - let new_name = String::from_str(&t.env, "Goods Dispatched"); - // 50+50+25 = 125 — must panic. - client.propose_amendment(&t.buyer, &shipment_id, &0, &50, &new_name); - client.propose_amendment(&t.supplier, &shipment_id, &0, &50, &new_name); -} - -// ============================================================ -// ARITHMETIC / OVERFLOW EDGE-CASE TESTS -// ============================================================ - -#[test] -fn test_payment_arithmetic_1e18_non_integer_division() { - // Use a large total_amount (1e18 stroops) and non-divisible milestone percents - // to exercise rounding behaviour. We assert the contract's per-milestone - // integer division behaviour (floor) results in the final milestone - // receiving the remainder so that the sum equals `total_amount`. - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::StellarAssetClient::new(&t.env, &t.token_id); - - // Ensure buyer has sufficient balance for large escrow. - token_client.mint(&t.buyer, &5_000_000_000_000_000_000i128); - - let shipment_id = String::from_str(&t.env, "SHIP-ARITH-1E18"); - let total_amount: i128 = 1_000_000_000_000_000_000i128; // 1e18 - - // Milestones: 33%, 33%, 34% → non-integer splits for 1e18 - let milestones = vec![ - &t.env, - Milestone { - name: String::from_str(&t.env, "A"), - payment_percent: 33, - proof_hash: String::from_str(&t.env, ""), - status: MilestoneStatus::Pending, - release_after_ledger: 0, - proof_submitted_ledger: None, - dispute_opened_ledger: None, - }, - Milestone { - name: String::from_str(&t.env, "B"), - payment_percent: 33, - proof_hash: String::from_str(&t.env, ""), - status: MilestoneStatus::Pending, - release_after_ledger: 0, - proof_submitted_ledger: None, - dispute_opened_ledger: None, - }, - Milestone { - name: String::from_str(&t.env, "C"), - payment_percent: 34, - proof_hash: String::from_str(&t.env, ""), - status: MilestoneStatus::Pending, - release_after_ledger: 0, - proof_submitted_ledger: None, - dispute_opened_ledger: None, - }, - ]; - - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &total_amount, - &milestones, - &default_options(&t.env), - ); - - // Submit and confirm all milestones sequentially. - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://a"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - client.submit_proof( - &t.supplier, - &shipment_id, - &1, - &String::from_str(&t.env, "ipfs://b"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &1); - - client.submit_proof( - &t.supplier, - &shipment_id, - &2, - &String::from_str(&t.env, "ipfs://c"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &2); - - // Compute expected per-milestone payments using integer division semantics. - let m0 = total_amount * 33 / 100; - let m1 = total_amount * 33 / 100; - let m2 = total_amount - m0 - m1; // remainder ensures sum == total_amount - - assert_eq!(m0 + m1 + m2, total_amount); - let balance_client = token::Client::new(&t.env, &t.token_id); - assert_eq!(balance_client.balance(&t.supplier), total_amount); - assert_eq!(client.get_escrow_balance(&shipment_id), 0); -} - -#[test] -fn test_payment_percent_extremes_99_1_no_overflow() { - // Test a 99/1 split with a large total amount to ensure no overflow - // and that payments sum to the original amount (no dust lost). - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::StellarAssetClient::new(&t.env, &t.token_id); - - // Allow 1% milestones for this arithmetic test. - client.set_min_milestone_percent(&t.buyer, &1u32); - - token_client.mint(&t.buyer, &5_000_000_000_000_000_000i128); - - let shipment_id = String::from_str(&t.env, "SHIP-ARITH-99-1"); - let total_amount: i128 = 1_000_000_000_000_000_000i128; // 1e18 - - let milestones = vec![ - &t.env, - Milestone { - name: String::from_str(&t.env, "Big"), - payment_percent: 99, - proof_hash: String::from_str(&t.env, ""), - status: MilestoneStatus::Pending, - release_after_ledger: 0, - proof_submitted_ledger: None, - dispute_opened_ledger: None, - }, - Milestone { - name: String::from_str(&t.env, "Small"), - payment_percent: 1, - proof_hash: String::from_str(&t.env, ""), - status: MilestoneStatus::Pending, - release_after_ledger: 0, - proof_submitted_ledger: None, - dispute_opened_ledger: None, - }, - ]; - - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &total_amount, - &milestones, - &default_options(&t.env), - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://x"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - client.submit_proof( - &t.supplier, - &shipment_id, - &1, - &String::from_str(&t.env, "ipfs://y"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &1); - - let p0 = total_amount * 99 / 100; - let p1 = total_amount - p0; // remainder - - assert_eq!(p0 + p1, total_amount); - let balance_client = token::Client::new(&t.env, &t.token_id); - assert_eq!(balance_client.balance(&t.supplier), total_amount); - assert_eq!(client.get_escrow_balance(&shipment_id), 0); -} - -// ============================================================ -// SUPPLIER CANCEL TESTS -// ============================================================ - -#[test] -fn test_deadline_cancellation_success() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "SHIP-SUPCANCEL"); - let total_amount: i128 = 1_000_000_000; - let deadline: u32 = 100; - let penalty_bps: u32 = 500; - - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &total_amount, - &build_milestones(&t.env), - &ShipmentOptions { - response_deadline: deadline, - penalty_bps, - milestone_mode: MilestoneMode::Parallel, - holdback_ledgers: 0, - dispute_cooldown_ledgers: 0, - late_penalty_bps_per_ledger: 0, - auto_confirm_ledgers: 0, - dispute_bond_amount: 0, - arbiter_fee_bps: 0, - logistics_fee_bps: 0, - supplier_collateral: 0, - expires_at_ledger: None, - }, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - t.env.ledger().set_sequence_number(deadline + 1); - - let buyer_balance_before = token_client.balance(&t.buyer); - client.supplier_cancel(&t.supplier, &shipment_id); - - let penalty = total_amount * penalty_bps as i128 / 10_000; - let refund = total_amount - penalty; - - assert_eq!(token_client.balance(&t.supplier), penalty); - assert_eq!( - token_client.balance(&t.buyer), - buyer_balance_before + refund - ); - assert_eq!( - client.get_shipment(&shipment_id).status, - ShipmentStatus::Cancelled - ); -} - -#[test] -#[should_panic(expected = "buyer response deadline has not passed")] -fn test_deadline_cancellation_too_early() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-PREMATURE"); - - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &1_000_000_000, - &build_milestones(&t.env), - &ShipmentOptions { - response_deadline: 1000, - penalty_bps: 500, - milestone_mode: MilestoneMode::Parallel, - holdback_ledgers: 0, - dispute_cooldown_ledgers: 0, - late_penalty_bps_per_ledger: 0, - auto_confirm_ledgers: 0, - dispute_bond_amount: 0, - arbiter_fee_bps: 0, - logistics_fee_bps: 0, - supplier_collateral: 0, - expires_at_ledger: None, - }, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.supplier_cancel(&t.supplier, &shipment_id); -} - -// ============================================================ -// UPGRADE TESTS -// ============================================================ - -#[test] -#[should_panic(expected = "unauthorized")] -fn test_upgrade_non_admin_rejected() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let fake_hash = BytesN::from_array(&t.env, &[0u8; 32]); - client.upgrade(&t.supplier, &fake_hash); -} - -// ============================================================ -// DISPUTE RESOLVE — FEE ON APPROVE -// ============================================================ - -#[test] -fn test_fee_deducted_on_dispute_resolve_approve() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let fee_bps: u32 = 200; // 2% - client.set_fee_config(&t.buyer, &fee_bps, &t.treasury); - - let shipment_id = String::from_str(&t.env, "SHIP-FEE-DISP"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.raise_dispute(&t.buyer, &shipment_id, &0); - client.resolve_dispute(&t.arbiter, &shipment_id, &0, &true); - - let gross = total_amount * 25 / 100; - let fee = gross * fee_bps as i128 / 10_000; - let net = gross - fee; - - assert_eq!(token_client.balance(&t.supplier), net); - assert_eq!(token_client.balance(&t.treasury), fee); -} - -// ============================================================ -// GET_COMPLETION_PERCENTAGE TESTS -// ============================================================ - -#[test] -fn test_get_completion_percentage_fresh_shipment() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-COMPL-FRESH"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - // Freshly created shipment with no milestones confirmed should return 0% - assert_eq!(client.get_completion_percentage(&shipment_id), 0); -} - -#[test] -fn test_get_completion_percentage_partial_one_milestone() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-COMPL-PARTIAL-1"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - // Confirm first milestone (25%) - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - // Should return 25% - assert_eq!(client.get_completion_percentage(&shipment_id), 25); -} - -#[test] -fn test_get_completion_percentage_partial_two_milestones() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-COMPL-PARTIAL-2"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - // Confirm first milestone (25%) - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - // Confirm second milestone (50% cumulative = 75% total) - client.submit_proof( - &t.logistics, - &shipment_id, - &1, - &String::from_str(&t.env, "ipfs://t"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &1); - - // Should return 75% - assert_eq!(client.get_completion_percentage(&shipment_id), 75); -} - -#[test] -fn test_get_completion_percentage_full_completion() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-COMPL-FULL"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - // Confirm all milestones - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - client.submit_proof( - &t.logistics, - &shipment_id, - &1, - &String::from_str(&t.env, "ipfs://t"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &1); - - client.submit_proof( - &t.supplier, - &shipment_id, - &2, - &String::from_str(&t.env, "ipfs://v"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &2); - - // Should return 100% - assert_eq!(client.get_completion_percentage(&shipment_id), 100); -} - -#[test] -fn test_get_completion_percentage_zero_released() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-COMPL-ZERO"); - let total_amount: i128 = 100; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - // Before any confirmation, released_amount is 0, should return 0% - assert_eq!(client.get_completion_percentage(&shipment_id), 0); - - // Confirm first milestone (25 out of 100 = 25%) - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - // (25 * 100) / 100 = 25% - assert_eq!(client.get_completion_percentage(&shipment_id), 25); -} - -// ============================================================ -// EVENT PAYLOAD CORRECTNESS TESTS (Issue #50) -// ============================================================ - -#[test] -fn test_shipment_created_event_includes_all_role_addresses() { - // Verifies create_shipment emits an event with all four role addresses, - // token, total_amount, and ledger embedded in the payload. - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let shipment_id = String::from_str(&t.env, "SHIP-EVT-CREATE"); - let total_amount: i128 = 1_000_000_000; - - // Advance ledger so created_at is non-zero. - t.env.ledger().with_mut(|l| l.sequence_number = 1); - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - // The event payload encodes the same data that is persisted in the shipment. - let shipment = client.get_shipment(&shipment_id); - assert_eq!( - shipment.buyers.get(0).unwrap(), - t.buyer, - "event: buyer matches" - ); - assert_eq!(shipment.supplier, t.supplier, "event: supplier matches"); - assert_eq!(shipment.logistics, t.logistics, "event: logistics matches"); - assert_eq!(shipment.arbiter, t.arbiter, "event: arbiter matches"); - assert_eq!(shipment.token, t.token_id, "event: token matches"); - assert_eq!( - shipment.total_amount, total_amount, - "event: total_amount matches" - ); - assert!(shipment.created_at > 0, "event: ledger field is non-zero"); -} - -#[test] -fn test_shipment_cancelled_event_includes_refund_and_cancelled_by() { - // Verifies cancel_shipment emits (refunded_amount, cancelled_by, ledger). - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - let shipment_id = String::from_str(&t.env, "SHIP-EVT-CANCEL"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - let buyer_before = token_client.balance(&t.buyer); - client.cancel_shipment(&t.buyer, &shipment_id); - - // refunded_amount: no milestones confirmed so the full escrow is returned. - let refund = token_client.balance(&t.buyer) - buyer_before; - assert_eq!(refund, total_amount, "event refunded_amount = full escrow"); - - // cancelled_by: the buyer who called cancel_shipment. - let shipment = client.get_shipment(&shipment_id); - assert_eq!(shipment.status, ShipmentStatus::Cancelled); - assert_eq!( - shipment.buyers.get(0).unwrap(), - t.buyer, - "event cancelled_by = buyer" - ); -} - -#[test] -fn test_shipment_cancelled_partial_refund_event_data() { - // Verifies that when some milestones are already confirmed, cancelled_amount reflects - // only the unconfirmed portion (matching the event's refunded_amount field). - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - let shipment_id = String::from_str(&t.env, "SHIP-EVT-CANCEL-P"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - // Confirm milestone 0 (25%). - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - let buyer_before = token_client.balance(&t.buyer); - client.cancel_shipment(&t.buyer, &shipment_id); - - // Remaining 75% is refunded; event refunded_amount should reflect this. - let refund = token_client.balance(&t.buyer) - buyer_before; - assert_eq!( - refund, - total_amount * 75 / 100, - "event refunded_amount = 75% of escrow" - ); -} - -#[test] -fn test_milestone_confirmed_event_includes_supplier_and_ledger() { - // Verifies confirm_milestone emits (index, payment, fee, penalty, supplier, ledger). - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - let shipment_id = String::from_str(&t.env, "SHIP-EVT-CONFIRM"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - - let supplier_before = token_client.balance(&t.supplier); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - // payment = 25% of total_amount (matches event payment field). - let expected_payment: i128 = total_amount * 25 / 100; - assert_eq!( - token_client.balance(&t.supplier) - supplier_before, - expected_payment, - "event payment = milestone payment_percent * total_amount / 100" - ); - - // supplier field in the event matches the stored shipment.supplier. - let shipment = client.get_shipment(&shipment_id); - assert_eq!( - shipment.supplier, t.supplier, - "event supplier field is correct (confirm)" - ); -} - -#[test] -fn test_batch_confirm_milestone_confirmed_event_includes_supplier() { - // Verifies batch_confirm_milestones emits milestone_confirmed with supplier and ledger. - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - let shipment_id = String::from_str(&t.env, "SHIP-EVT-BATCH"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.submit_proof( - &t.logistics, - &shipment_id, - &1, - &String::from_str(&t.env, "ipfs://t"), - - &Symbol::new(&t.env, "ipfs"),); - - let supplier_before = token_client.balance(&t.supplier); - client.batch_confirm_milestones(&t.buyer, &shipment_id, &vec![&t.env, 0u32, 1u32]); - - // Both milestones paid to supplier: 25% + 50% = 75%. - let expected_payment: i128 = total_amount * 75 / 100; - assert_eq!( - token_client.balance(&t.supplier) - supplier_before, - expected_payment, - "batch event payments sum to 75% of total_amount" - ); - - let shipment = client.get_shipment(&shipment_id); - assert_eq!( - shipment.supplier, t.supplier, - "event supplier field is correct" - ); -} - -// ============================================================ -// CONCURRENT/PARALLEL SHIPMENT STRESS TEST (Issue #57) -// ============================================================ - -#[test] -fn test_concurrent_100_shipments_stress() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_admin_client = token::StellarAssetClient::new(&t.env, &t.token_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - const NUM_SHIPMENTS: u32 = 100; - const AMOUNT_PER_SHIPMENT: i128 = 1_000_000; - const TOTAL_FUNDING_NEEDED: i128 = NUM_SHIPMENTS as i128 * AMOUNT_PER_SHIPMENT; - - // Mint enough tokens for all shipments - token_admin_client.mint(&t.buyer, &TOTAL_FUNDING_NEEDED); - - // Create 100 unique suppliers to track individual balances - let mut suppliers: soroban_sdk::Vec
= soroban_sdk::Vec::new(&t.env); - for _ in 0..NUM_SHIPMENTS { - suppliers.push_back(Address::generate(&t.env)); - } - - // Phase 1: Create 100 shipments with unique IDs - for i in 0..NUM_SHIPMENTS { - let shipment_id = String::from_str(&t.env, &format!("STRESS-{:03}", i)); - let supplier = suppliers.get(i).unwrap(); - - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &AMOUNT_PER_SHIPMENT, - &build_milestones(&t.env), // 3 milestones: 25%, 50%, 25% - &default_options(&t.env), - ); - } - - // Phase 2: Submit proofs in random/interleaved order - // We'll use a pseudo-random pattern: reverse order for milestone 0, - // forward order for milestone 1, alternating for milestone 2 - - // Milestone 0: reverse order (99, 98, 97, ..., 0) - for i in (0..NUM_SHIPMENTS).rev() { - let shipment_id = String::from_str(&t.env, &format!("STRESS-{:03}", i)); - let supplier = suppliers.get(i).unwrap(); - client.submit_proof( - &supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://proof0"), - - &Symbol::new(&t.env, "ipfs"),); - } - - // Milestone 1: forward order (0, 1, 2, ..., 99) - for i in 0..NUM_SHIPMENTS { - let shipment_id = String::from_str(&t.env, &format!("STRESS-{:03}", i)); - client.submit_proof( - &t.logistics, - &shipment_id, - &1, - &String::from_str(&t.env, "ipfs://proof1"), - - &Symbol::new(&t.env, "ipfs"),); - } - - // Milestone 2: alternating order (0, 99, 1, 98, 2, 97, ...) - let mut indices: soroban_sdk::Vec = soroban_sdk::Vec::new(&t.env); - for i in 0..NUM_SHIPMENTS / 2 { - indices.push_back(i); - indices.push_back(NUM_SHIPMENTS - 1 - i); - } - for i in 0..indices.len() { - let idx = indices.get(i).unwrap(); - let shipment_id = String::from_str(&t.env, &format!("STRESS-{:03}", idx)); - let supplier = suppliers.get(idx).unwrap(); - client.submit_proof( - &supplier, - &shipment_id, - &2, - &String::from_str(&t.env, "ipfs://proof2"), - - &Symbol::new(&t.env, "ipfs"),); - } - - // Phase 3: Confirm milestones in random/interleaved order - // Similar pattern: different order for each milestone - - // Confirm milestone 0: every 3rd shipment first, then fill gaps - for i in (0..NUM_SHIPMENTS).step_by(3) { - let shipment_id = String::from_str(&t.env, &format!("STRESS-{:03}", i)); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - } - for i in (1..NUM_SHIPMENTS).step_by(3) { - let shipment_id = String::from_str(&t.env, &format!("STRESS-{:03}", i)); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - } - for i in (2..NUM_SHIPMENTS).step_by(3) { - let shipment_id = String::from_str(&t.env, &format!("STRESS-{:03}", i)); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - } - - // Confirm milestone 1: reverse order - for i in (0..NUM_SHIPMENTS).rev() { - let shipment_id = String::from_str(&t.env, &format!("STRESS-{:03}", i)); - client.confirm_milestone(&t.buyer, &shipment_id, &1); - } - - // Confirm milestone 2: forward order - for i in 0..NUM_SHIPMENTS { - let shipment_id = String::from_str(&t.env, &format!("STRESS-{:03}", i)); - client.confirm_milestone(&t.buyer, &shipment_id, &2); - } - - // Phase 4: Verify all shipments reached Completed status - let mut completed_count = 0; - for i in 0..NUM_SHIPMENTS { - let shipment_id = String::from_str(&t.env, &format!("STRESS-{:03}", i)); - let shipment = client.get_shipment(&shipment_id); - - assert_eq!( - shipment.status, - ShipmentStatus::Completed, - "Shipment {} should be Completed", - i - ); - - // Verify released_amount equals total_amount - assert_eq!( - shipment.released_amount, shipment.total_amount, - "Shipment {} released_amount should equal total_amount", - i - ); - - assert_eq!( - shipment.released_amount, AMOUNT_PER_SHIPMENT, - "Shipment {} released_amount should be {}", - i, AMOUNT_PER_SHIPMENT - ); - - completed_count += 1; - } - - assert_eq!( - completed_count, NUM_SHIPMENTS, - "All {} shipments should be completed", - NUM_SHIPMENTS - ); - - // Phase 5: Verify supplier token balances match expected payments - for i in 0..NUM_SHIPMENTS { - let supplier = suppliers.get(i).unwrap(); - let balance = token_client.balance(&supplier); - - // Each supplier should have received exactly AMOUNT_PER_SHIPMENT - // (25% + 50% + 25% = 100% of AMOUNT_PER_SHIPMENT) - assert_eq!( - balance, AMOUNT_PER_SHIPMENT, - "Supplier {} balance should be {}", - i, AMOUNT_PER_SHIPMENT - ); - } - - // Phase 6: Verify total token distribution - let total_supplier_balance: i128 = (0..NUM_SHIPMENTS) - .map(|i| token_client.balance(&suppliers.get(i).unwrap())) - .sum(); - - assert_eq!( - total_supplier_balance, TOTAL_FUNDING_NEEDED, - "Total supplier balances should equal total funding" - ); - - // Verify contract escrow is empty (all funds released) - let contract_balance = token_client.balance(&t.contract_id); - assert_eq!( - contract_balance, 0, - "Contract should have zero balance after all shipments completed" - ); - - // Verify no escrow balance remains for any shipment - for i in 0..NUM_SHIPMENTS { - let shipment_id = String::from_str(&t.env, &format!("STRESS-{:03}", i)); - let escrow_balance = client.get_escrow_balance(&shipment_id); - assert_eq!( - escrow_balance, 0, - "Shipment {} should have zero escrow balance", - i - ); - } -} - -#[test] -fn test_concurrent_shipments_with_different_amounts() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_admin_client = token::StellarAssetClient::new(&t.env, &t.token_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - const NUM_SHIPMENTS: u32 = 100; - - // Calculate total funding needed (sum of 1M, 2M, 3M, ..., 100M) - let total_funding: i128 = (1..=NUM_SHIPMENTS as i128).map(|i| i * 1_000_000).sum(); - - token_admin_client.mint(&t.buyer, &total_funding); - - // Create suppliers - let mut suppliers: soroban_sdk::Vec
= soroban_sdk::Vec::new(&t.env); - for _ in 0..NUM_SHIPMENTS { - suppliers.push_back(Address::generate(&t.env)); - } - - // Create shipments with varying amounts - for i in 0..NUM_SHIPMENTS { - let shipment_id = String::from_str(&t.env, &format!("VAR-{:03}", i)); - let supplier = suppliers.get(i).unwrap(); - let amount = ((i + 1) as i128) * 1_000_000; // 1M, 2M, 3M, ..., 100M - - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &amount, - &build_milestones(&t.env), - &default_options(&t.env), - ); - } - - // Complete all shipments in interleaved order - for milestone_idx in 0..3u32 { - // Submit proofs - for i in 0..NUM_SHIPMENTS { - let shipment_id = String::from_str(&t.env, &format!("VAR-{:03}", i)); - let supplier = suppliers.get(i).unwrap(); - let proof = String::from_str(&t.env, &format!("ipfs://proof{}", milestone_idx)); - - if milestone_idx == 1 { - client.submit_proof(&t.logistics, &shipment_id, &milestone_idx, &proof, &Symbol::new(&t.env, "ipfs")); - } else { - client.submit_proof(&supplier, &shipment_id, &milestone_idx, &proof, &Symbol::new(&t.env, "ipfs")); - } - } - - // Confirm milestones - for i in 0..NUM_SHIPMENTS { - let shipment_id = String::from_str(&t.env, &format!("VAR-{:03}", i)); - client.confirm_milestone(&t.buyer, &shipment_id, &milestone_idx); - } - } - - // Verify each shipment completed with correct amount - for i in 0..NUM_SHIPMENTS { - let shipment_id = String::from_str(&t.env, &format!("VAR-{:03}", i)); - let shipment = client.get_shipment(&shipment_id); - let expected_amount = ((i + 1) as i128) * 1_000_000; - - assert_eq!(shipment.status, ShipmentStatus::Completed); - assert_eq!(shipment.released_amount, expected_amount); - - // Verify supplier received correct amount - let supplier = suppliers.get(i).unwrap(); - let balance = token_client.balance(&supplier); - assert_eq!(balance, expected_amount); - } - - // Verify total distribution - let total_distributed: i128 = (0..NUM_SHIPMENTS) - .map(|i| token_client.balance(&suppliers.get(i).unwrap())) - .sum(); - - assert_eq!(total_distributed, total_funding); -} - -#[test] -fn test_concurrent_shipments_no_storage_clobbering() { - // This test specifically checks for global storage clobbering bugs - // by creating shipments with similar data but different IDs - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_admin_client = token::StellarAssetClient::new(&t.env, &t.token_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - const NUM_SHIPMENTS: u32 = 100; - const AMOUNT: i128 = 1_000_000; - - token_admin_client.mint(&t.buyer, &(NUM_SHIPMENTS as i128 * AMOUNT)); - - // Use the same supplier for all shipments to test storage isolation - let shared_supplier = Address::generate(&t.env); - - // Create all shipments - for i in 0..NUM_SHIPMENTS { - let shipment_id = String::from_str(&t.env, &format!("CLOB-{:03}", i)); - - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &shared_supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &AMOUNT, - &build_milestones(&t.env), - &default_options(&t.env), - ); - } - - // Submit proof for milestone 0 on all shipments - for i in 0..NUM_SHIPMENTS { - let shipment_id = String::from_str(&t.env, &format!("CLOB-{:03}", i)); - client.submit_proof( - &shared_supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://m0"), - - &Symbol::new(&t.env, "ipfs"),); - } - - // Verify each shipment has independent state - for i in 0..NUM_SHIPMENTS { - let shipment_id = String::from_str(&t.env, &format!("CLOB-{:03}", i)); - let milestone = client.get_milestone(&shipment_id, &0); - - assert_eq!( - milestone.status, - MilestoneStatus::ProofSubmitted, - "Shipment {} milestone 0 should be ProofSubmitted", - i - ); - - // Verify other milestones are still Pending - let m1 = client.get_milestone(&shipment_id, &1); - let m2 = client.get_milestone(&shipment_id, &2); - assert_eq!(m1.status, MilestoneStatus::Pending); - assert_eq!(m2.status, MilestoneStatus::Pending); - } - - // Confirm milestone 0 on every other shipment - for i in (0..NUM_SHIPMENTS).step_by(2) { - let shipment_id = String::from_str(&t.env, &format!("CLOB-{:03}", i)); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - } - - // Verify state divergence: even shipments confirmed, odd still have proof submitted - for i in 0..NUM_SHIPMENTS { - let shipment_id = String::from_str(&t.env, &format!("CLOB-{:03}", i)); - let milestone = client.get_milestone(&shipment_id, &0); - - if i % 2 == 0 { - assert_eq!( - milestone.status, - MilestoneStatus::Confirmed, - "Even shipment {} milestone 0 should be Confirmed", - i - ); - } else { - assert_eq!( - milestone.status, - MilestoneStatus::ProofSubmitted, - "Odd shipment {} milestone 0 should still be ProofSubmitted", - i - ); - } - } - - // Complete all shipments - for i in 0..NUM_SHIPMENTS { - let shipment_id = String::from_str(&t.env, &format!("CLOB-{:03}", i)); - - // Confirm milestone 0 if not already confirmed - if i % 2 != 0 { - client.confirm_milestone(&t.buyer, &shipment_id, &0); - } - - // Complete remaining milestones - for m in 1..3u32 { - client.submit_proof( - &t.logistics, - &shipment_id, - &m, - &String::from_str(&t.env, &format!("ipfs://m{}", m)), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &m); - } - } - - // Final verification: all completed, correct amounts - for i in 0..NUM_SHIPMENTS { - let shipment_id = String::from_str(&t.env, &format!("CLOB-{:03}", i)); - let shipment = client.get_shipment(&shipment_id); - - assert_eq!(shipment.status, ShipmentStatus::Completed); - assert_eq!(shipment.released_amount, AMOUNT); - } - - // Verify shared supplier received all payments - let supplier_balance = token_client.balance(&shared_supplier); - assert_eq!(supplier_balance, NUM_SHIPMENTS as i128 * AMOUNT); -} - -// ============================================================ -// SUPPLIER ADVANCE PAYMENT TESTS -// ============================================================ - -#[test] -fn test_advance_approved_deducted_on_confirm() { - // Verify: Supplier requests advance → buyer approves → advance transferred - // → on confirm, advance deducted from milestone payment. - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "SHIP-ADV-DEDUCT"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - // Milestone 0 is 25%. Advance 30% of that = 7.5% of total = 75_000_000. - let milestone_payment = total_amount * 25 / 100; // 250_000_000 - let advance_percent: u32 = 30; - let expected_advance = milestone_payment * advance_percent as i128 / 100; // 75_000_000 - - let supplier_before = token_client.balance(&t.supplier); - - // Supplier requests advance. - client.request_advance(&t.supplier, &shipment_id, &0, &advance_percent); - - // Buyer approves advance. - client.approve_advance(&t.buyer, &shipment_id, &0); - - // Advance should be transferred immediately. - assert_eq!( - token_client.balance(&t.supplier), - supplier_before + expected_advance, - "advance transferred on approval" - ); - - // Escrow balance should reflect the advance. - assert_eq!( - client.get_escrow_balance(&shipment_id), - total_amount - expected_advance, - "escrow reduced by advance" - ); - - // Submit proof and confirm milestone. - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - // On confirm, supplier should receive milestone_payment - advance. - assert_eq!( - token_client.balance(&t.supplier), - supplier_before + milestone_payment, // advance + remaining = full payment - "supplier received full milestone payment over two transfers" - ); - - // Shipment should show correct released amount. - let shipment = client.get_shipment(&shipment_id); - assert_eq!(shipment.released_amount, milestone_payment); - assert_eq!(shipment.total_advanced_amount, 0, "advance consumed"); - - // Escrow balance should now reflect the milestone payment. - assert_eq!( - client.get_escrow_balance(&shipment_id), - total_amount - milestone_payment, - ); -} - -#[test] -fn test_advance_exceeding_cap_rejected() { - // Verify: advance_percent > max_advance_percent is rejected. - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-ADV-CAP"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - // Default max is 30%. Try 31%. - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - client.request_advance(&t.supplier, &shipment_id, &0, &31); - })); - assert!(result.is_err(), "advance > 30% should be rejected"); -} - -#[test] -fn test_advance_exceeding_custom_cap_rejected() { - // Verify: admin can change max advance percent; new cap enforced. - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - // Admin sets max to 10%. - client.set_max_advance_percent(&t.buyer, &10); - assert_eq!(client.get_max_advance_percent(), 10); - - let shipment_id = String::from_str(&t.env, "SHIP-ADV-CUSTOM"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - // 10% should work. - client.request_advance(&t.supplier, &shipment_id, &0, &10); - - // 11% should be rejected. - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - client.request_advance(&t.supplier, &shipment_id, &1, &11); - })); - assert!(result.is_err(), "advance > 10% should be rejected after update"); -} - -#[test] -fn test_unapproved_advance_no_funds_moved() { - // Verify: requesting advance does NOT transfer funds until approved. - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "SHIP-ADV-NOOP"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - let supplier_before = token_client.balance(&t.supplier); - - // Supplier requests advance — no funds should move. - client.request_advance(&t.supplier, &shipment_id, &0, &20); - - assert_eq!( - token_client.balance(&t.supplier), - supplier_before, - "no funds transferred on request alone" - ); - - // Escrow balance unchanged. - assert_eq!(client.get_escrow_balance(&shipment_id), total_amount); -} - -#[test] -fn test_advance_only_supplier_can_request() { - // Verify: only the shipment's supplier can request an advance. - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-ADV-AUTH"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - // Logistics is not the supplier — should be rejected. - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - client.request_advance(&t.logistics, &shipment_id, &0, &20); - })); - assert!(result.is_err(), "non-supplier should be rejected"); -} - -#[test] -fn test_advance_only_buyer_can_approve() { - // Verify: only a buyer can approve an advance. - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-ADV-APPROVE-AUTH"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - client.request_advance(&t.supplier, &shipment_id, &0, &20); - - // Supplier tries to approve their own advance — should be rejected. - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - client.approve_advance(&t.supplier, &shipment_id, &0); - })); - assert!(result.is_err(), "non-buyer should not approve advance"); -} - -#[test] -fn test_advance_multi_milestone_deductions() { - // Verify: advances on multiple milestones are deducted independently. - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "SHIP-ADV-MULTI"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - // Milestones: 25%, 50%, 25% - // Advance 20% on milestone 0 (25% of total = 250M → advance = 50M) - // Advance 10% on milestone 1 (50% of total = 500M → advance = 50M) - let m0_payment = total_amount * 25 / 100; - let m1_payment = total_amount * 50 / 100; - let _m2_payment = total_amount - m0_payment - m1_payment; - - let advance0 = m0_payment * 20 / 100; - let advance1 = m1_payment * 10 / 100; - - let supplier_before = token_client.balance(&t.supplier); - - // Request and approve advance for milestone 0. - client.request_advance(&t.supplier, &shipment_id, &0, &20); - client.approve_advance(&t.buyer, &shipment_id, &0); - assert_eq!( - token_client.balance(&t.supplier), - supplier_before + advance0, - ); - - // Request and approve advance for milestone 1. - client.request_advance(&t.supplier, &shipment_id, &1, &10); - client.approve_advance(&t.buyer, &shipment_id, &1); - assert_eq!( - token_client.balance(&t.supplier), - supplier_before + advance0 + advance1, - ); - - // Confirm milestone 0. - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://m0"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - // Supplier gets m0_payment - advance0 extra. - assert_eq!( - token_client.balance(&t.supplier), - supplier_before + advance0 + advance1 + (m0_payment - advance0), - ); - - // Confirm milestone 1. - client.submit_proof( - &t.logistics, - &shipment_id, - &1, - &String::from_str(&t.env, "ipfs://m1"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &1); - - assert_eq!( - token_client.balance(&t.supplier), - supplier_before + advance0 + advance1 + (m0_payment - advance0) + (m1_payment - advance1), - ); - - // Confirm milestone 2 (no advance). - client.submit_proof( - &t.supplier, - &shipment_id, - &2, - &String::from_str(&t.env, "ipfs://m2"), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &2); - - // Total should be full amount. - assert_eq!( - token_client.balance(&t.supplier), - supplier_before + total_amount, - ); - - let shipment = client.get_shipment(&shipment_id); - assert_eq!(shipment.status, ShipmentStatus::Completed); - assert_eq!(shipment.released_amount, total_amount); - assert_eq!(shipment.total_advanced_amount, 0, "all advances consumed"); - assert_eq!(client.get_escrow_balance(&shipment_id), 0); -} - -#[test] -fn test_advance_rejected_for_wrong_milestone_status() { - // Verify: advance can only be requested on a Pending milestone. - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-ADV-STATUS"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - // Submit proof for milestone 0. - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "ipfs://d"), - - &Symbol::new(&t.env, "ipfs"),); - - // Advance on non-Pending milestone should be rejected. - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - client.request_advance(&t.supplier, &shipment_id, &0, &10); - })); - assert!(result.is_err(), "advance on non-pending milestone should be rejected"); -} - -#[test] -fn test_advance_double_approval_rejected() { - // Verify: approving an already-approved advance is rejected. - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-ADV-DBL"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - client.request_advance(&t.supplier, &shipment_id, &0, &20); - client.approve_advance(&t.buyer, &shipment_id, &0); - - // Second approval should be rejected. - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - client.approve_advance(&t.buyer, &shipment_id, &0); - })); - assert!(result.is_err(), "double approval should be rejected"); -} - -#[test] -fn test_advance_no_request_no_approval() { - // Verify: approving without a prior request is rejected. - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "SHIP-ADV-NOREQ"); - let total_amount: i128 = 1_000_000_000; - - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - // Approve without prior request. - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - client.approve_advance(&t.buyer, &shipment_id, &0); - })); - assert!(result.is_err(), "approval without request should be rejected"); -} - -// ============================================================ -// TTL EXPIRY SIMULATION TESTS (Issue #58) -// ============================================================ - -/// TTL (Time To Live) constants used in production code. -/// These values determine how long data persists in Soroban storage before archival. -/// -/// TTL_INITIAL_LEDGERS: 100,000 ledgers (~5.8 days at 5s/ledger) -/// - Initial TTL set when data is first written -/// - Minimum threshold for extend_ttl calls -/// -/// TTL_MAX_LEDGERS: 6,300,000 ledgers (~1 year at 5s/ledger) -/// - Maximum TTL that can be set -/// - Upper bound for extend_ttl calls -/// -/// Storage behavior: -/// - Data is accessible while current_ledger < (last_extended_ledger + TTL) -/// - After TTL expires, data is archived and requires restoration -/// - extend_ttl() resets the expiry window from current ledger - -#[test] -fn test_ttl_shipment_accessible_within_window() { - // Test that shipment data remains accessible within the TTL window - // Expected TTL: 100,000 ledgers (~5.8 days) - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "TTL-WITHIN"); - let total_amount: i128 = 1_000_000_000; - - // Create shipment at ledger 1000 - t.env.ledger().set_sequence_number(1000); - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - // Verify accessible immediately after creation - let shipment = client.get_shipment(&shipment_id); - assert_eq!(shipment.status, ShipmentStatus::Active); - assert_eq!(shipment.total_amount, total_amount); - - // Advance to ledger 50,000 (well within TTL_INITIAL_LEDGERS of 100,000) - t.env.ledger().set_sequence_number(50_000); - let shipment = client.get_shipment(&shipment_id); - assert_eq!(shipment.status, ShipmentStatus::Active); - - // Advance to ledger 99,999 (just before TTL expiry) - t.env.ledger().set_sequence_number(99_999); - let shipment = client.get_shipment(&shipment_id); - assert_eq!(shipment.status, ShipmentStatus::Active); - assert_eq!(shipment.id, shipment_id); -} - -#[test] -fn test_ttl_supplier_index_extends() { - // Test that supplier index TTL is extended on shipment creation - // Note: This test verifies the shipment itself persists, which implies - // the supplier index is also maintained (internal implementation detail) - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "TTL-SUPPLIER-IDX"); - let total_amount: i128 = 1_000_000_000; - - // Create at ledger 2000 - t.env.ledger().set_sequence_number(2000); - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - // Advance to ledger 90,000 (within TTL) - t.env.ledger().set_sequence_number(90_000); - - // Shipment should still be accessible (implies indexes are maintained) - let shipment = client.get_shipment(&shipment_id); - assert_eq!(shipment.supplier, t.supplier); - assert_eq!(shipment.status, ShipmentStatus::Active); -} - -#[test] -fn test_ttl_buyer_index_extends() { - // Test that buyer index TTL is extended on shipment creation - // Note: This test verifies the shipment itself persists, which implies - // the buyer index is also maintained (internal implementation detail) - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "TTL-BUYER-IDX"); - let total_amount: i128 = 1_000_000_000; - - // Create at ledger 3000 - t.env.ledger().set_sequence_number(3000); - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - // Advance to ledger 95,000 (within TTL) - t.env.ledger().set_sequence_number(95_000); - - // Shipment should still be accessible (implies indexes are maintained) - let shipment = client.get_shipment(&shipment_id); - assert_eq!(shipment.buyers.get(0).unwrap(), t.buyer); - assert_eq!(shipment.status, ShipmentStatus::Active); -} - -#[test] -fn test_ttl_completed_shipment_persists() { - // Test that completed shipments persist within TTL window - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "TTL-COMPLETED"); - let total_amount: i128 = 1_000_000_000; - - // Create at ledger 4000 - t.env.ledger().set_sequence_number(4000); - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - // Complete all milestones at ledger 5000 - t.env.ledger().set_sequence_number(5000); - for i in 0..3u32 { - client.submit_proof( - &t.supplier, - &shipment_id, - &i, - &String::from_str(&t.env, &std::format!("ipfs://m{}", i)), - - &Symbol::new(&t.env, "ipfs"),); - client.confirm_milestone(&t.buyer, &shipment_id, &i); - } - - let shipment = client.get_shipment(&shipment_id); - assert_eq!(shipment.status, ShipmentStatus::Completed); - - // Advance to ledger 90,000 (within TTL from last update at 5000) - t.env.ledger().set_sequence_number(90_000); - - // Completed shipment should still be accessible - let shipment = client.get_shipment(&shipment_id); - assert_eq!(shipment.status, ShipmentStatus::Completed); - assert_eq!(shipment.released_amount, total_amount); -} - -#[test] -fn test_ttl_constants_documented() { - // This test documents the TTL constants used in production - // TTL_INITIAL_LEDGERS: Minimum TTL set on data writes - // TTL_MAX_LEDGERS: Maximum TTL that can be set - - use crate::constants::{TTL_INITIAL_LEDGERS, TTL_MAX_LEDGERS}; - - // Verify constants match expected values - assert_eq!( - TTL_INITIAL_LEDGERS, 100_000, - "TTL_INITIAL_LEDGERS should be 100,000 ledgers (~5.8 days)" - ); - assert_eq!( - TTL_MAX_LEDGERS, 6_300_000, - "TTL_MAX_LEDGERS should be 6,300,000 ledgers (~1 year)" - ); - - // Document time calculations (at 5 seconds per ledger) - let initial_days = (TTL_INITIAL_LEDGERS * 5) / 86_400; - let max_days = (TTL_MAX_LEDGERS * 5) / 86_400; - - assert_eq!(initial_days, 5, "TTL_INITIAL_LEDGERS ≈ 5.8 days"); - assert_eq!(max_days, 364, "TTL_MAX_LEDGERS ≈ 1 year"); -} - -#[test] -fn test_ttl_extend_parameters_verified() { - // This test verifies that extend_ttl is called with correct parameters - // in the storage layer - - use crate::constants::{TTL_INITIAL_LEDGERS, TTL_MAX_LEDGERS}; - - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "TTL-PARAMS"); - let total_amount: i128 = 1_000_000_000; - - // Create shipment - this calls extend_ttl internally - t.env.ledger().set_sequence_number(1000); - create_standard_shipment( - &client, - &t.env, - &shipment_id, - &t.buyer, - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - total_amount, - ); - - // Verify shipment is accessible (extend_ttl was called correctly) - let shipment = client.get_shipment(&shipment_id); - assert_eq!(shipment.status, ShipmentStatus::Active); - - // Advance to near TTL_INITIAL_LEDGERS boundary - t.env - .ledger() - .set_sequence_number(1000 + TTL_INITIAL_LEDGERS - 1); - let shipment = client.get_shipment(&shipment_id); - assert_eq!(shipment.status, ShipmentStatus::Active); - - // Verify TTL parameters are as expected - assert_eq!(TTL_INITIAL_LEDGERS, 100_000); - assert_eq!(TTL_MAX_LEDGERS, 6_300_000); -} - -#[test] -fn test_ttl_documentation_in_comments() { - // This test serves as documentation for TTL behavior - // - // TTL (Time To Live) in Soroban: - // - Persistent storage entries have a TTL measured in ledgers - // - When TTL expires, data is archived (not deleted) - // - Archived data requires restoration before access - // - extend_ttl(threshold, max) sets: TTL = min(current_ledger + max, last_access + threshold) - // - // ChainSettle TTL Strategy: - // - TTL_INITIAL_LEDGERS (100,000): ~5.8 days minimum lifetime - // - TTL_MAX_LEDGERS (6,300,000): ~1 year maximum lifetime - // - Every write operation extends TTL - // - Read operations do NOT extend TTL - // - // Production Implications: - // - Active shipments stay accessible indefinitely (writes extend TTL) - // - Inactive shipments archive after ~5.8 days - // - Backend should call extend_ttl for important historical data - // - Archived data can be restored via Soroban RPC - - use crate::constants::{TTL_INITIAL_LEDGERS, TTL_MAX_LEDGERS}; - - // Verify constants are properly defined - assert!( - TTL_INITIAL_LEDGERS > 0, - "TTL_INITIAL_LEDGERS must be positive" - ); - assert!( - TTL_MAX_LEDGERS > TTL_INITIAL_LEDGERS, - "TTL_MAX_LEDGERS must exceed TTL_INITIAL_LEDGERS" - ); - - // Document expected durations - let initial_seconds = TTL_INITIAL_LEDGERS * 5; - let max_seconds = TTL_MAX_LEDGERS * 5; - - assert_eq!( - initial_seconds, 500_000, - "TTL_INITIAL_LEDGERS = 500,000 seconds" - ); - assert_eq!( - max_seconds, 31_500_000, - "TTL_MAX_LEDGERS = 31,500,000 seconds" - ); -} - -// ============================================================ -// NEW FEATURES TESTS -// ============================================================ - -#[test] -fn test_logistics_fee_deduction() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "logistics-fee-1"); - let mut opts = default_options(&t.env); - opts.logistics_fee_bps = 500; // 5% logistics fee - - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &1_000_000_000i128, - &build_milestones(&t.env), - &opts, - ); - - // Submit and confirm proof for first milestone (25% = 250,000,000) - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "proof_hash_0"), - &Symbol::new(&t.env, "ipfs"), - ); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - // Logistics fee = 250,000,000 * 500 / 10,000 = 12,500,000 - // Supplier should receive: 250,000,000 - 12,500,000 = 237,500,000 - let supplier_balance = token_client.balance(&t.supplier); - assert_eq!(supplier_balance, 237_500_000, "Supplier should receive payment minus logistics fee"); - - let logistics_balance = token_client.balance(&t.logistics); - assert_eq!(logistics_balance, 12_500_000, "Logistics provider should receive logistics fee"); -} - -#[test] -fn test_logistics_fee_zero() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "no-logistics-fee"); - let opts = default_options(&t.env); - - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &1_000_000_000i128, - &build_milestones(&t.env), - &opts, - ); - - client.submit_proof( - &t.supplier, - &shipment_id, - &0, - &String::from_str(&t.env, "proof_hash_0"), - &Symbol::new(&t.env, "ipfs"), - ); - client.confirm_milestone(&t.buyer, &shipment_id, &0); - - // With zero logistics fee, full payment goes to supplier - let supplier_balance = token_client.balance(&t.supplier); - assert_eq!(supplier_balance, 250_000_000, "Supplier should receive full payment"); - - let logistics_balance = token_client.balance(&t.logistics); - assert_eq!(logistics_balance, 0, "Logistics provider should receive nothing"); -} - -#[test] -fn test_supplier_collateral_forfeiture_on_buyer_cancel() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "collateral-1"); - let mut opts = default_options(&t.env); - opts.supplier_collateral = 50_000_000; // Supplier must lock 50M - - // Mint additional funds for supplier - token_client.mint(&t.supplier, &100_000_000); - - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &1_000_000_000i128, - &build_milestones(&t.env), - &opts, - ); - - // Buyer cancels, collateral should be forfeited to buyer - client.cancel_shipment(&t.buyer, &shipment_id); - - let buyer_balance = token_client.balance(&t.buyer); - // Original 10B - 1B (shipment) + 1B (full refund) + 50M (collateral forfeiture) - assert_eq!( - buyer_balance, 10_050_000_000, - "Buyer should receive collateral forfeiture on cancellation" - ); -} - -#[test] -fn test_supplier_collateral_return_on_completion() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "collateral-complete"); - let mut opts = default_options(&t.env); - opts.supplier_collateral = 50_000_000; - - token_client.mint(&t.supplier, &100_000_000); - - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &1_000_000_000i128, - &build_milestones(&t.env), - &opts, - ); - - let initial_supplier_balance = token_client.balance(&t.supplier); - - // Complete all milestones - for i in 0..3 { - client.submit_proof( - &t.supplier, - &shipment_id, - &i, - &String::from_str(&t.env, &format!("proof_hash_{}", i)), - &Symbol::new(&t.env, "ipfs"), - ); - client.confirm_milestone(&t.buyer, &shipment_id, &i); - } - - let final_supplier_balance = token_client.balance(&t.supplier); - // Supplier should have: initial + all milestone payments + returned collateral - // All milestones: 1B total (no deductions from original supply in this test) - assert!( - final_supplier_balance > initial_supplier_balance, - "Supplier should receive collateral return on completion" - ); -} - -#[test] -fn test_shipment_expiry_success() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - let token_client = token::Client::new(&t.env, &t.token_id); - - let shipment_id = String::from_str(&t.env, "expiry-1"); - let mut opts = default_options(&t.env); - opts.expires_at_ledger = Some(t.env.ledger().sequence() + 10); - - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &1_000_000_000i128, - &build_milestones(&t.env), - &opts, - ); - - // Try to expire before deadline (should fail) - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - client.expire_shipment(&shipment_id); - })); - assert!(result.is_err(), "Should not allow expiry before deadline"); - - // Jump to after expiry ledger - t.env.ledger().set_sequence(t.env.ledger().sequence() + 11); - - // Now expiry should succeed - client.expire_shipment(&shipment_id); - - let buyer_balance = token_client.balance(&t.buyer); - // Full refund: 10B (original) - 1B (locked) + 1B (refund) = 10B - assert_eq!(buyer_balance, 10_000_000_000, "Buyer should receive full escrow refund on expiry"); -} - -#[test] -fn test_shipment_no_expiry() { - let t = setup(); - let client = ChainSettleContractClient::new(&t.env, &t.contract_id); - - let shipment_id = String::from_str(&t.env, "no-expiry"); - let opts = default_options(&t.env); - - client.create_shipment( - &shipment_id, - &single_buyer_vec(&t.env, &t.buyer), - &t.supplier, - &t.logistics, - &t.arbiter, - &t.token_id, - &1_000_000_000i128, - &build_milestones(&t.env), - &opts, - ); - - // Try to expire when no expiry is set (should fail) - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - client.expire_shipment(&shipment_id); - })); - assert!(result.is_err(), "Should not allow expiry when not configured"); -} +mod test_common; +mod test_shipment; +mod test_dispute; +mod test_admin; +mod test_query; +// Additional test modules (to be created): +// mod test_transfer; +// mod test_escrow; +// mod test_features; +// mod test_edge_cases; +// mod test_concurrent; +// mod test_advances; + +// Re-export common test utilities for use in other modules +pub use test_common::*; diff --git a/contracts/chainsetttle/src/test_admin.rs b/contracts/chainsetttle/src/test_admin.rs new file mode 100644 index 0000000..44fc2ab --- /dev/null +++ b/contracts/chainsetttle/src/test_admin.rs @@ -0,0 +1,487 @@ +#![cfg(test)] + +extern crate std; + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger as _, Symbol}, + token, Address, BytesN, String, vec, +}; +use crate::test_common::*; + +// ============================================================ +// ADMIN CONTROL TESTS: PAUSE/UNPAUSE +// ============================================================ + +#[test] +fn test_pause_blocks_state_changes() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + + // Pause the contract (buyer is admin in setup). + client.pause(&t.buyer); + assert!(client.is_paused()); +} + +#[test] +#[should_panic(expected = "contract is paused")] +fn test_pause_blocks_create_shipment() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + + client.pause(&t.buyer); + + create_standard_shipment( + &client, + &t.env, + &String::from_str(&t.env, "SHIP-PAUSED"), + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + 1_000_000_000, + ); +} + +#[test] +fn test_unpause_restores_operations() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + + client.pause(&t.buyer); + assert!(client.is_paused()); + + client.unpause(&t.buyer); + assert!(!client.is_paused()); + + // Should succeed after unpause. + let shipment_id = String::from_str(&t.env, "SHIP-UNPAUSED"); + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + 1_000_000_000, + ); + assert_eq!( + client.get_shipment(&shipment_id).status, + ShipmentStatus::Active + ); +} + +#[test] +#[should_panic(expected = "contract is paused")] +fn test_pause_blocks_confirm_milestone() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + + let shipment_id = String::from_str(&t.env, "SHIP-PAUSE-CONF"); + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + 1_000_000_000, + ); + client.submit_proof( + &t.supplier, + &shipment_id, + &0, + &String::from_str(&t.env, "ipfs://d"), + &Symbol::new(&t.env, "ipfs"), + ); + + client.pause(&t.buyer); + client.confirm_milestone(&t.buyer, &shipment_id, &0); +} + +#[test] +fn test_read_only_accessible_while_paused() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + + let shipment_id = String::from_str(&t.env, "SHIP-READ-PAUSED"); + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + 1_000_000_000, + ); + + client.pause(&t.buyer); + + // Read-only calls must still work. + let shipment = client.get_shipment(&shipment_id); + assert_eq!(shipment.status, ShipmentStatus::Active); + + let milestone = client.get_milestone(&shipment_id, &0); + assert_eq!(milestone.status, MilestoneStatus::Pending); + + let balance = client.get_escrow_balance(&shipment_id); + assert_eq!(balance, 1_000_000_000); +} + +#[test] +#[should_panic(expected = "unauthorized")] +fn test_non_admin_cannot_pause() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + // supplier is not admin + client.pause(&t.supplier); +} + +// ============================================================ +// ADMIN CONTROL TESTS: MIN MILESTONE PERCENT & SETTINGS +// ============================================================ + +#[test] +fn test_min_milestone_percent_accepts_threshold() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + + client.set_min_milestone_percent(&t.buyer, &5); + + let shipment_id = String::from_str(&t.env, "SHIP-MIN-OK"); + let milestones = vec![ + &t.env, + Milestone { + name: String::from_str(&t.env, "Goods Dispatched"), + payment_percent: 5, + proof_hash: String::from_str(&t.env, ""), + status: MilestoneStatus::Pending, + release_after_ledger: 0, + proof_submitted_ledger: None, + dispute_opened_ledger: None, + }, + Milestone { + name: String::from_str(&t.env, "In Transit"), + payment_percent: 45, + proof_hash: String::from_str(&t.env, ""), + status: MilestoneStatus::Pending, + release_after_ledger: 0, + proof_submitted_ledger: None, + dispute_opened_ledger: None, + }, + Milestone { + name: String::from_str(&t.env, "Delivered"), + payment_percent: 50, + proof_hash: String::from_str(&t.env, ""), + status: MilestoneStatus::Pending, + release_after_ledger: 0, + proof_submitted_ledger: None, + dispute_opened_ledger: None, + }, + ]; + + client.create_shipment( + &shipment_id, + &single_buyer_vec(&t.env, &t.buyer), + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + &1_000_000_000, + &milestones, + &default_options(&t.env), + ); + + assert_eq!( + client.get_shipment(&shipment_id).status, + ShipmentStatus::Active + ); +} + +#[test] +#[should_panic(expected = "InvalidPercentages")] +fn test_min_milestone_percent_rejects_below_threshold() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + + let shipment_id = String::from_str(&t.env, "SHIP-MIN-BAD"); + let milestones = vec![ + &t.env, + Milestone { + name: String::from_str(&t.env, "Goods Dispatched"), + payment_percent: 4, + proof_hash: String::from_str(&t.env, ""), + status: MilestoneStatus::Pending, + release_after_ledger: 0, + proof_submitted_ledger: None, + dispute_opened_ledger: None, + }, + Milestone { + name: String::from_str(&t.env, "In Transit"), + payment_percent: 46, + proof_hash: String::from_str(&t.env, ""), + status: MilestoneStatus::Pending, + release_after_ledger: 0, + proof_submitted_ledger: None, + dispute_opened_ledger: None, + }, + Milestone { + name: String::from_str(&t.env, "Delivered"), + payment_percent: 50, + proof_hash: String::from_str(&t.env, ""), + status: MilestoneStatus::Pending, + release_after_ledger: 0, + proof_submitted_ledger: None, + dispute_opened_ledger: None, + }, + ]; + + client.create_shipment( + &shipment_id, + &single_buyer_vec(&t.env, &t.buyer), + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + &1_000_000_000, + &milestones, + &default_options(&t.env), + ); +} + +#[test] +fn test_min_milestone_percent_updates_via_admin() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + + client.set_min_milestone_percent(&t.buyer, &10); + assert_eq!(client.get_admin_log().len() >= 1, true); + + let shipment_id = String::from_str(&t.env, "SHIP-MIN-UPDATE"); + let milestones = vec![ + &t.env, + Milestone { + name: String::from_str(&t.env, "Goods Dispatched"), + payment_percent: 10, + proof_hash: String::from_str(&t.env, ""), + status: MilestoneStatus::Pending, + release_after_ledger: 0, + proof_submitted_ledger: None, + dispute_opened_ledger: None, + }, + Milestone { + name: String::from_str(&t.env, "In Transit"), + payment_percent: 40, + proof_hash: String::from_str(&t.env, ""), + status: MilestoneStatus::Pending, + release_after_ledger: 0, + proof_submitted_ledger: None, + dispute_opened_ledger: None, + }, + Milestone { + name: String::from_str(&t.env, "Delivered"), + payment_percent: 50, + proof_hash: String::from_str(&t.env, ""), + status: MilestoneStatus::Pending, + release_after_ledger: 0, + proof_submitted_ledger: None, + dispute_opened_ledger: None, + }, + ]; + + client.create_shipment( + &shipment_id, + &single_buyer_vec(&t.env, &t.buyer), + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + &1_000_000_000, + &milestones, + &default_options(&t.env), + ); + assert_eq!( + client.get_shipment(&shipment_id).status, + ShipmentStatus::Active + ); +} + +#[test] +#[should_panic(expected = "unauthorized")] +fn test_non_admin_cannot_update_min_milestone_percent() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + client.set_min_milestone_percent(&t.supplier, &7); +} + +#[test] +fn test_admin_action_log_capped_and_ordered() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + + for i in 1..=51 { + let pct = if i <= 100 { i as u32 } else { 100 }; + t.env.ledger().with_mut(|l| l.sequence_number += 1); + client.set_min_milestone_percent(&t.buyer, &pct); + } + + let log = client.get_admin_log(); + assert_eq!(log.len(), 50); + for i in 1..log.len() { + assert!(log.get(i - 1).unwrap().ledger <= log.get(i).unwrap().ledger); + } +} + +#[test] +#[should_panic(expected = "unauthorized")] +fn test_upgrade_non_admin_rejected() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + + let fake_hash = BytesN::from_array(&t.env, &[0u8; 32]); + client.upgrade(&t.supplier, &fake_hash); +} + +// ============================================================ +// ADMIN CONTROL TESTS: BLACKLIST +// ============================================================ + +#[test] +#[should_panic(expected = "unauthorized")] +fn test_blacklisted_buyer_blocks_create_shipment() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + let reason = BytesN::from_array(&t.env, &[0u8; 32]); + client.blacklist_address(&t.buyer, &t.buyer, &reason); + + let shipment_id = String::from_str(&t.env, "SHIP-BLACK-BUYER"); + client.create_shipment( + &shipment_id, + &single_buyer_vec(&t.env, &t.buyer), + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + &1_000_000_000, + &build_milestones(&t.env), + &default_options(&t.env), + ); +} + +#[test] +fn test_blacklist_removal_restores_participation() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + let reason = BytesN::from_array(&t.env, &[1u8; 32]); + client.blacklist_address(&t.buyer, &t.supplier, &reason); + assert!(client.is_blacklisted(&t.supplier)); + + client.remove_from_blacklist(&t.buyer, &t.supplier); + assert!(!client.is_blacklisted(&t.supplier)); + + let shipment_id = String::from_str(&t.env, "SHIP-BLACK-RESTORED"); + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + 1_000_000_000, + ); + assert_eq!( + client.get_shipment(&shipment_id).status, + ShipmentStatus::Active + ); +} + +#[test] +#[should_panic(expected = "unauthorized")] +fn test_blacklisted_arbiter_blocks_create_shipment() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + let reason = BytesN::from_array(&t.env, &[2u8; 32]); + client.blacklist_address(&t.buyer, &t.arbiter, &reason); + + let shipment_id = String::from_str(&t.env, "SHIP-BLACK-ARBITER"); + client.create_shipment( + &shipment_id, + &single_buyer_vec(&t.env, &t.buyer), + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + &1_000_000_000, + &build_milestones(&t.env), + &default_options(&t.env), + ); +} + +#[test] +fn test_existing_shipment_works_after_post_creation_blacklist() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + let shipment_id = String::from_str(&t.env, "SHIP-BLACK-EXISTING"); + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + 1_000_000_000, + ); + let reason = BytesN::from_array(&t.env, &[3u8; 32]); + client.blacklist_address(&t.buyer, &t.supplier, &reason); + + assert_eq!( + client.get_shipment(&shipment_id).status, + ShipmentStatus::Active + ); + client.submit_proof( + &t.supplier, + &shipment_id, + &0, + &String::from_str(&t.env, "ipfs://d"), + &Symbol::new(&t.env, "ipfs"), + ); + assert_eq!( + client.get_milestone(&shipment_id, &0).status, + MilestoneStatus::ProofSubmitted + ); +} + +#[test] +#[should_panic(expected = "unauthorized")] +fn test_top_up_non_buyer_rejected() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + + let shipment_id = String::from_str(&t.env, "SHIP-TOPUP-AUTH"); + + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + 1_000_000_000, + ); + + // Supplier is not a buyer — must be rejected. + client.top_up_escrow(&t.supplier, &shipment_id, &100_000); +} diff --git a/contracts/chainsetttle/src/test_common.rs b/contracts/chainsetttle/src/test_common.rs new file mode 100644 index 0000000..2a15ae8 --- /dev/null +++ b/contracts/chainsetttle/src/test_common.rs @@ -0,0 +1,139 @@ +#![cfg(test)] + +extern crate std; + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger as _, Symbol}, + token, vec, Address, BytesN, Env, String, +}; +use std::format; + +// ============================================================ +// TEST SETUP & SHARED FIXTURES +// ============================================================ + +pub struct TestSetup { + pub env: Env, + pub contract_id: Address, + pub token_id: Address, + pub buyer: Address, + pub buyer2: Address, + pub supplier: Address, + pub logistics: Address, + pub arbiter: Address, + pub treasury: Address, +} + +pub fn setup() -> TestSetup { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(ChainSettleContract, ()); + + let token_admin = Address::generate(&env); + let token_id = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let token_client = token::StellarAssetClient::new(&env, &token_id); + + let buyer = Address::generate(&env); + let buyer2 = Address::generate(&env); + let supplier = Address::generate(&env); + let logistics = Address::generate(&env); + let arbiter = Address::generate(&env); + let treasury = Address::generate(&env); + + token_client.mint(&buyer, &10_000_000_000); + token_client.mint(&buyer2, &10_000_000_000); + + let client = ChainSettleContractClient::new(&env, &contract_id); + client.init(&buyer); + + TestSetup { + env, + contract_id, + token_id, + buyer, + buyer2, + supplier, + logistics, + arbiter, + treasury, + } +} + +pub fn build_milestones(env: &Env) -> soroban_sdk::Vec { + vec![ + env, + Milestone { + name: String::from_str(env, "Goods Dispatched"), + payment_percent: 25, + proof_hash: String::from_str(env, ""), + status: MilestoneStatus::Pending, + release_after_ledger: 0, + proof_submitted_ledger: None, + dispute_opened_ledger: None, + }, + Milestone { + name: String::from_str(env, "In Transit"), + payment_percent: 50, + proof_hash: String::from_str(env, ""), + status: MilestoneStatus::Pending, + release_after_ledger: 0, + proof_submitted_ledger: None, + dispute_opened_ledger: None, + }, + Milestone { + name: String::from_str(env, "Delivered"), + payment_percent: 25, + proof_hash: String::from_str(env, ""), + status: MilestoneStatus::Pending, + release_after_ledger: 0, + proof_submitted_ledger: None, + dispute_opened_ledger: None, + }, + ] +} + +pub fn single_buyer_vec(env: &Env, buyer: &Address) -> soroban_sdk::Vec
{ + vec![env, buyer.clone()] +} + +pub fn default_options(_env: &Env) -> ShipmentOptions { + ShipmentOptions { + response_deadline: 0, + penalty_bps: 0, + milestone_mode: MilestoneMode::Parallel, + holdback_ledgers: 0, + dispute_cooldown_ledgers: 0, + late_penalty_bps_per_ledger: 0, + auto_confirm_ledgers: 0, + dispute_bond_amount: 0, + } +} + +/// Create a standard shipment with no deadline, no penalty, parallel mode, no holdback, no cooldown. +pub fn create_standard_shipment( + client: &ChainSettleContractClient, + env: &Env, + shipment_id: &String, + buyer: &Address, + supplier: &Address, + logistics: &Address, + arbiter: &Address, + token_id: &Address, + total_amount: i128, +) { + client.create_shipment( + shipment_id, + &single_buyer_vec(env, buyer), + supplier, + logistics, + arbiter, + token_id, + &total_amount, + &build_milestones(env), + &default_options(env), + ); +} diff --git a/contracts/chainsetttle/src/test_dispute.rs b/contracts/chainsetttle/src/test_dispute.rs new file mode 100644 index 0000000..f505179 --- /dev/null +++ b/contracts/chainsetttle/src/test_dispute.rs @@ -0,0 +1,365 @@ +#![cfg(test)] + +extern crate std; + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger as _, Symbol}, + token, Address, String, vec, +}; +use crate::test_common::*; + +// ============================================================ +// DISPUTE TESTS +// ============================================================ + +#[test] +#[should_panic(expected = "DisputeAlreadyOpen")] +fn test_dispute_limit_blocks_second() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + let shipment_id = String::from_str(&t.env, "SHIP-DISP-LIMIT"); + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + 1_000_000_000, + ); + + client.submit_proof( + &t.supplier, + &shipment_id, + &0, + &String::from_str(&t.env, "ipfs://d"), + &Symbol::new(&t.env, "ipfs"), + ); + client.submit_proof( + &t.supplier, + &shipment_id, + &1, + &String::from_str(&t.env, "ipfs://t"), + &Symbol::new(&t.env, "ipfs"), + ); + client.raise_dispute(&t.buyer, &shipment_id, &0); + assert_eq!(client.get_shipment(&shipment_id).open_dispute_count, 1); + client.raise_dispute(&t.buyer, &shipment_id, &1); +} + +#[test] +fn test_dispute_limit_frees_slot_on_resolution() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + let shipment_id = String::from_str(&t.env, "SHIP-DISP-RESOLVE"); + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + 1_000_000_000, + ); + + client.submit_proof( + &t.supplier, + &shipment_id, + &0, + &String::from_str(&t.env, "ipfs://d"), + &Symbol::new(&t.env, "ipfs"), + ); + client.raise_dispute(&t.buyer, &shipment_id, &0); + assert_eq!(client.get_shipment(&shipment_id).open_dispute_count, 1); + client.resolve_dispute(&t.arbiter, &shipment_id, &0, &false); + assert_eq!(client.get_shipment(&shipment_id).open_dispute_count, 0); +} + +#[test] +fn test_dispute_limit_two_allows_two_concurrent_disputes() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + client.set_max_concurrent_disputes(&t.buyer, &2); + + let shipment_id = String::from_str(&t.env, "SHIP-DISP-2"); + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + 1_000_000_000, + ); + + client.submit_proof( + &t.supplier, + &shipment_id, + &0, + &String::from_str(&t.env, "ipfs://d"), + &Symbol::new(&t.env, "ipfs"), + ); + client.submit_proof( + &t.supplier, + &shipment_id, + &1, + &String::from_str(&t.env, "ipfs://t"), + &Symbol::new(&t.env, "ipfs"), + ); + + client.raise_dispute(&t.buyer, &shipment_id, &0); + client.raise_dispute(&t.buyer, &shipment_id, &1); + assert_eq!(client.get_shipment(&shipment_id).open_dispute_count, 2); +} + +#[test] +fn test_dispute_cooldown_enforced() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + + let shipment_id = String::from_str(&t.env, "SHIP-COOLDOWN"); + let cooldown: u32 = 100; + + client.create_shipment( + &shipment_id, + &single_buyer_vec(&t.env, &t.buyer), + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + &1_000_000_000, + &build_milestones(&t.env), + &ShipmentOptions { + response_deadline: 0, + penalty_bps: 0, + milestone_mode: MilestoneMode::Parallel, + holdback_ledgers: 0, + dispute_cooldown_ledgers: cooldown, + late_penalty_bps_per_ledger: 0, + auto_confirm_ledgers: 0, + dispute_bond_amount: 0, + }, + ); + + // First dispute on milestone 0. + client.submit_proof( + &t.supplier, + &shipment_id, + &0, + &String::from_str(&t.env, "ipfs://d"), + &Symbol::new(&t.env, "ipfs"), + ); + client.raise_dispute(&t.buyer, &shipment_id, &0); + // Arbiter rejects — milestone goes back to Pending, cooldown starts. + client.resolve_dispute(&t.arbiter, &shipment_id, &0, &false); + + // Resubmit proof for milestone 0. + client.submit_proof( + &t.supplier, + &shipment_id, + &0, + &String::from_str(&t.env, "ipfs://d2"), + &Symbol::new(&t.env, "ipfs"), + ); + + // Advance ledger past cooldown. + t.env.ledger().set_sequence_number(cooldown + 1); + + // Now dispute should succeed. + client.raise_dispute(&t.buyer, &shipment_id, &0); + assert_eq!( + client.get_milestone(&shipment_id, &0).status, + MilestoneStatus::Disputed + ); +} + +#[test] +#[should_panic(expected = "dispute cooldown period has not elapsed")] +fn test_dispute_cooldown_blocks_early_redispute() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + + let shipment_id = String::from_str(&t.env, "SHIP-COOLDOWN-BLOCK"); + let cooldown: u32 = 500; + + client.create_shipment( + &shipment_id, + &single_buyer_vec(&t.env, &t.buyer), + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + &1_000_000_000, + &build_milestones(&t.env), + &ShipmentOptions { + response_deadline: 0, + penalty_bps: 0, + milestone_mode: MilestoneMode::Parallel, + holdback_ledgers: 0, + dispute_cooldown_ledgers: cooldown, + late_penalty_bps_per_ledger: 0, + auto_confirm_ledgers: 0, + dispute_bond_amount: 0, + }, + ); + + client.submit_proof( + &t.supplier, + &shipment_id, + &0, + &String::from_str(&t.env, "ipfs://d"), + &Symbol::new(&t.env, "ipfs"), + ); + client.raise_dispute(&t.buyer, &shipment_id, &0); + client.resolve_dispute(&t.arbiter, &shipment_id, &0, &false); + + // Resubmit and immediately try to dispute again — must panic. + client.submit_proof( + &t.supplier, + &shipment_id, + &0, + &String::from_str(&t.env, "ipfs://d2"), + &Symbol::new(&t.env, "ipfs"), + ); + client.raise_dispute(&t.buyer, &shipment_id, &0); +} + +#[test] +fn test_no_cooldown_allows_immediate_redispute() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + + let shipment_id = String::from_str(&t.env, "SHIP-NO-COOLDOWN"); + + // cooldown = 0 means no restriction. + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + 1_000_000_000, + ); + + client.submit_proof( + &t.supplier, + &shipment_id, + &0, + &String::from_str(&t.env, "ipfs://d"), + &Symbol::new(&t.env, "ipfs"), + ); + client.raise_dispute(&t.buyer, &shipment_id, &0); + client.resolve_dispute(&t.arbiter, &shipment_id, &0, &false); + + client.submit_proof( + &t.supplier, + &shipment_id, + &0, + &String::from_str(&t.env, "ipfs://d2"), + &Symbol::new(&t.env, "ipfs"), + ); + // Should succeed immediately with no cooldown. + client.raise_dispute(&t.buyer, &shipment_id, &0); + assert_eq!( + client.get_milestone(&shipment_id, &0).status, + MilestoneStatus::Disputed + ); +} + +#[test] +fn test_cooldown_updated_on_resolve() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + + let shipment_id = String::from_str(&t.env, "SHIP-COOLDOWN-UPD"); + let cooldown: u32 = 50; + + client.create_shipment( + &shipment_id, + &single_buyer_vec(&t.env, &t.buyer), + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + &1_000_000_000, + &build_milestones(&t.env), + &ShipmentOptions { + response_deadline: 0, + penalty_bps: 0, + milestone_mode: MilestoneMode::Parallel, + holdback_ledgers: 0, + dispute_cooldown_ledgers: cooldown, + late_penalty_bps_per_ledger: 0, + auto_confirm_ledgers: 0, + dispute_bond_amount: 0, + }, + ); + + client.submit_proof( + &t.supplier, + &shipment_id, + &0, + &String::from_str(&t.env, "ipfs://d"), + &Symbol::new(&t.env, "ipfs"), + ); + client.raise_dispute(&t.buyer, &shipment_id, &0); + + t.env.ledger().set_sequence_number(10); + client.resolve_dispute(&t.arbiter, &shipment_id, &0, &false); + + // last_dispute_resolved_ledger should now be 10. + let shipment = client.get_shipment(&shipment_id); + assert_eq!(shipment.last_dispute_resolved_ledger, Some(10u32)); +} + +#[test] +fn test_fee_deducted_on_dispute_resolve_approve() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + let token_client = token::Client::new(&t.env, &t.token_id); + + let fee_bps: u32 = 200; // 2% + client.set_fee_config(&t.buyer, &fee_bps, &t.treasury); + + let shipment_id = String::from_str(&t.env, "SHIP-FEE-DISP"); + let total_amount: i128 = 1_000_000_000; + + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + total_amount, + ); + + client.submit_proof( + &t.supplier, + &shipment_id, + &0, + &String::from_str(&t.env, "ipfs://d"), + &Symbol::new(&t.env, "ipfs"), + ); + client.raise_dispute(&t.buyer, &shipment_id, &0); + client.resolve_dispute(&t.arbiter, &shipment_id, &0, &true); + + let gross = total_amount * 25 / 100; + let fee = gross * fee_bps as i128 / 10_000; + let net = gross - fee; + + assert_eq!(token_client.balance(&t.supplier), net); + assert_eq!(token_client.balance(&t.treasury), fee); +} diff --git a/contracts/chainsetttle/src/test_query.rs b/contracts/chainsetttle/src/test_query.rs new file mode 100644 index 0000000..411c390 --- /dev/null +++ b/contracts/chainsetttle/src/test_query.rs @@ -0,0 +1,205 @@ +#![cfg(test)] + +extern crate std; + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger as _, Symbol}, + token, String, +}; +use crate::test_common::*; + +// ============================================================ +// QUERY & READ-ONLY TESTS: COMPLETION PERCENTAGE +// ============================================================ + +#[test] +fn test_get_completion_percentage_fresh_shipment() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + + let shipment_id = String::from_str(&t.env, "SHIP-COMPL-FRESH"); + let total_amount: i128 = 1_000_000_000; + + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + total_amount, + ); + + // Freshly created shipment with no milestones confirmed should return 0% + assert_eq!(client.get_completion_percentage(&shipment_id), 0); +} + +#[test] +fn test_get_completion_percentage_partial_one_milestone() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + + let shipment_id = String::from_str(&t.env, "SHIP-COMPL-PARTIAL-1"); + let total_amount: i128 = 1_000_000_000; + + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + total_amount, + ); + + // Confirm first milestone (25%) + client.submit_proof( + &t.supplier, + &shipment_id, + &0, + &String::from_str(&t.env, "ipfs://d"), + &Symbol::new(&t.env, "ipfs"), + ); + client.confirm_milestone(&t.buyer, &shipment_id, &0); + + // Should return 25% + assert_eq!(client.get_completion_percentage(&shipment_id), 25); +} + +#[test] +fn test_get_completion_percentage_partial_two_milestones() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + + let shipment_id = String::from_str(&t.env, "SHIP-COMPL-PARTIAL-2"); + let total_amount: i128 = 1_000_000_000; + + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + total_amount, + ); + + // Confirm first milestone (25%) + client.submit_proof( + &t.supplier, + &shipment_id, + &0, + &String::from_str(&t.env, "ipfs://d"), + &Symbol::new(&t.env, "ipfs"), + ); + client.confirm_milestone(&t.buyer, &shipment_id, &0); + + // Confirm second milestone (50% cumulative = 75% total) + client.submit_proof( + &t.logistics, + &shipment_id, + &1, + &String::from_str(&t.env, "ipfs://t"), + &Symbol::new(&t.env, "ipfs"), + ); + client.confirm_milestone(&t.buyer, &shipment_id, &1); + + // Should return 75% + assert_eq!(client.get_completion_percentage(&shipment_id), 75); +} + +#[test] +fn test_get_completion_percentage_full_completion() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + + let shipment_id = String::from_str(&t.env, "SHIP-COMPL-FULL"); + let total_amount: i128 = 1_000_000_000; + + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + total_amount, + ); + + // Confirm all milestones + client.submit_proof( + &t.supplier, + &shipment_id, + &0, + &String::from_str(&t.env, "ipfs://d"), + &Symbol::new(&t.env, "ipfs"), + ); + client.confirm_milestone(&t.buyer, &shipment_id, &0); + + client.submit_proof( + &t.logistics, + &shipment_id, + &1, + &String::from_str(&t.env, "ipfs://t"), + &Symbol::new(&t.env, "ipfs"), + ); + client.confirm_milestone(&t.buyer, &shipment_id, &1); + + client.submit_proof( + &t.supplier, + &shipment_id, + &2, + &String::from_str(&t.env, "ipfs://v"), + &Symbol::new(&t.env, "ipfs"), + ); + client.confirm_milestone(&t.buyer, &shipment_id, &2); + + // Should return 100% + assert_eq!(client.get_completion_percentage(&shipment_id), 100); +} + +#[test] +fn test_get_completion_percentage_zero_released() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + + let shipment_id = String::from_str(&t.env, "SHIP-COMPL-ZERO"); + let total_amount: i128 = 100; + + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + total_amount, + ); + + // Before any confirmation, released_amount is 0, should return 0% + assert_eq!(client.get_completion_percentage(&shipment_id), 0); + + // Confirm first milestone (25 out of 100 = 25%) + client.submit_proof( + &t.supplier, + &shipment_id, + &0, + &String::from_str(&t.env, "ipfs://d"), + &Symbol::new(&t.env, "ipfs"), + ); + client.confirm_milestone(&t.buyer, &shipment_id, &0); + + // (25 * 100) / 100 = 25% + assert_eq!(client.get_completion_percentage(&shipment_id), 25); +} diff --git a/contracts/chainsetttle/src/test_shipment.rs b/contracts/chainsetttle/src/test_shipment.rs new file mode 100644 index 0000000..c0b7f86 --- /dev/null +++ b/contracts/chainsetttle/src/test_shipment.rs @@ -0,0 +1,383 @@ +#![cfg(test)] + +extern crate std; + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger as _, Symbol}, + token, Address, String, vec, +}; +use crate::test_common::*; + +// ============================================================ +// CORE LIFECYCLE TESTS +// ============================================================ + +#[test] +fn test_create_shipment_success() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + let token_client = token::Client::new(&t.env, &t.token_id); + + let shipment_id = String::from_str(&t.env, "SHIP-001"); + let total_amount: i128 = 1_000_000_000; + + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + total_amount, + ); + + assert_eq!( + token_client.balance(&t.buyer), + 10_000_000_000 - total_amount + ); + assert_eq!(token_client.balance(&t.contract_id), total_amount); + + let shipment = client.get_shipment(&shipment_id); + assert_eq!(shipment.status, ShipmentStatus::Active); + assert_eq!(shipment.total_amount, total_amount); + assert_eq!(shipment.released_amount, 0); + assert_eq!(shipment.milestones.len(), 3); + assert_eq!(shipment.holdback_ledgers, 0); + assert_eq!(shipment.dispute_cooldown_ledgers, 0); +} + +#[test] +#[should_panic(expected = "milestone percentages must sum to 100")] +fn test_create_shipment_invalid_percentages() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + + let bad_milestones = vec![ + &t.env, + Milestone { + name: String::from_str(&t.env, "Step 1"), + payment_percent: 30, + proof_hash: String::from_str(&t.env, ""), + status: MilestoneStatus::Pending, + release_after_ledger: 0, + proof_submitted_ledger: None, + dispute_opened_ledger: None, + }, + Milestone { + name: String::from_str(&t.env, "Step 2"), + payment_percent: 30, + proof_hash: String::from_str(&t.env, ""), + status: MilestoneStatus::Pending, + release_after_ledger: 0, + proof_submitted_ledger: None, + dispute_opened_ledger: None, + }, + Milestone { + name: String::from_str(&t.env, "Step 3"), + payment_percent: 30, + proof_hash: String::from_str(&t.env, ""), + status: MilestoneStatus::Pending, + release_after_ledger: 0, + proof_submitted_ledger: None, + dispute_opened_ledger: None, + }, + ]; + + client.create_shipment( + &String::from_str(&t.env, "SHIP-BAD"), + &single_buyer_vec(&t.env, &t.buyer), + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + &1_000_000_000, + &bad_milestones, + &default_options(&t.env), + ); +} + +#[test] +fn test_full_shipment_lifecycle() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + let token_client = token::Client::new(&t.env, &t.token_id); + + let shipment_id = String::from_str(&t.env, "SHIP-FULL"); + let total_amount: i128 = 1_000_000_000; + + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + total_amount, + ); + + client.submit_proof( + &t.supplier, + &shipment_id, + &0, + &String::from_str(&t.env, "ipfs://d"), + &Symbol::new(&t.env, "ipfs"), + ); + client.confirm_milestone(&t.buyer, &shipment_id, &0); + + client.submit_proof( + &t.logistics, + &shipment_id, + &1, + &String::from_str(&t.env, "ipfs://t"), + &Symbol::new(&t.env, "ipfs"), + ); + client.confirm_milestone(&t.buyer, &shipment_id, &1); + + client.submit_proof( + &t.supplier, + &shipment_id, + &2, + &String::from_str(&t.env, "ipfs://v"), + &Symbol::new(&t.env, "ipfs"), + ); + client.confirm_milestone(&t.buyer, &shipment_id, &2); + + let shipment = client.get_shipment(&shipment_id); + assert_eq!(shipment.status, ShipmentStatus::Completed); + assert_eq!(shipment.released_amount, total_amount); + assert_eq!(token_client.balance(&t.supplier), total_amount); + assert_eq!(client.get_escrow_balance(&shipment_id), 0); +} + +#[test] +fn test_full_lifecycle_with_dispute() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + let token_client = token::Client::new(&t.env, &t.token_id); + + let shipment_id = String::from_str(&t.env, "SHIP-FULL-DISP"); + let total_amount: i128 = 1_000_000_000; + + let buyer_balance_before = token_client.balance(&t.buyer); + + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + total_amount, + ); + + // Milestone 0: supplier submits proof and buyer confirms -> payment released + client.submit_proof( + &t.supplier, + &shipment_id, + &0, + &String::from_str(&t.env, "ipfs://d0"), + &Symbol::new(&t.env, "ipfs"), + ); + client.confirm_milestone(&t.buyer, &shipment_id, &0); + + let m0_payment = total_amount * 25 / 100; + assert_eq!(token_client.balance(&t.supplier), m0_payment); + assert_eq!( + client.get_escrow_balance(&shipment_id), + total_amount - m0_payment + ); + + // Milestone 1: submit -> buyer disputes -> arbiter rejects -> supplier resubmits -> buyer confirms + client.submit_proof( + &t.supplier, + &shipment_id, + &1, + &String::from_str(&t.env, "ipfs://d1"), + &Symbol::new(&t.env, "ipfs"), + ); + client.raise_dispute(&t.buyer, &shipment_id, &1); + client.resolve_dispute(&t.arbiter, &shipment_id, &1, &false); + + // After reject, supplier resubmits proof and buyer confirms + client.submit_proof( + &t.supplier, + &shipment_id, + &1, + &String::from_str(&t.env, "ipfs://d1-resub"), + &Symbol::new(&t.env, "ipfs"), + ); + client.confirm_milestone(&t.buyer, &shipment_id, &1); + + let m1_payment = total_amount * 50 / 100; + assert_eq!(token_client.balance(&t.supplier), m0_payment + m1_payment); + assert_eq!( + client.get_escrow_balance(&shipment_id), + total_amount - m0_payment - m1_payment + ); + + // Milestone 2: submit and confirm -> final payment and completion + client.submit_proof( + &t.supplier, + &shipment_id, + &2, + &String::from_str(&t.env, "ipfs://d2"), + &Symbol::new(&t.env, "ipfs"), + ); + client.confirm_milestone(&t.buyer, &shipment_id, &2); + + let shipment = client.get_shipment(&shipment_id); + assert_eq!(shipment.status, ShipmentStatus::Completed); + assert_eq!(shipment.released_amount, total_amount); + + // Final balances: supplier gets full amount, buyer reduced by total_amount, contract escrow zero + assert_eq!(token_client.balance(&t.supplier), total_amount); + assert_eq!( + token_client.balance(&t.buyer), + buyer_balance_before - total_amount + ); + assert_eq!(client.get_escrow_balance(&shipment_id), 0); +} + +#[test] +fn test_cancel_shipment() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + let token_client = token::Client::new(&t.env, &t.token_id); + + let shipment_id = String::from_str(&t.env, "SHIP-CANCEL"); + let total_amount: i128 = 1_000_000_000; + let buyer_balance_before = token_client.balance(&t.buyer); + + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + total_amount, + ); + + client.cancel_shipment(&t.buyer, &shipment_id); + + let shipment = client.get_shipment(&shipment_id); + assert_eq!(shipment.status, ShipmentStatus::Cancelled); + assert_eq!(token_client.balance(&t.buyer), buyer_balance_before); +} + +#[test] +fn test_cancel_partial_confirmed() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + let token_client = token::Client::new(&t.env, &t.token_id); + + let shipment_id = String::from_str(&t.env, "SHIP-PARTIAL"); + let total_amount: i128 = 1_000_000_000; + + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + total_amount, + ); + + // Confirm milestone 0 (25%) + client.submit_proof( + &t.supplier, + &shipment_id, + &0, + &String::from_str(&t.env, "ipfs://d"), + &Symbol::new(&t.env, "ipfs"), + ); + client.confirm_milestone(&t.buyer, &shipment_id, &0); + + let buyer_balance_after_confirm = token_client.balance(&t.buyer); + client.cancel_shipment(&t.buyer, &shipment_id); + + // Buyer should get back 75% (the unconfirmed portion) + let expected_refund = total_amount * 75 / 100; + assert_eq!( + token_client.balance(&t.buyer), + buyer_balance_after_confirm + expected_refund + ); + assert_eq!( + client.get_shipment(&shipment_id).status, + ShipmentStatus::Cancelled + ); +} + +#[test] +#[should_panic(expected = "cannot cancel: dispute must be resolved first")] +fn test_cancel_blocked_by_dispute() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + + let shipment_id = String::from_str(&t.env, "SHIP-DISP-CANCEL"); + + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + 1_000_000_000, + ); + + client.submit_proof( + &t.supplier, + &shipment_id, + &0, + &String::from_str(&t.env, "ipfs://d"), + &Symbol::new(&t.env, "ipfs"), + ); + client.raise_dispute(&t.buyer, &shipment_id, &0); + client.cancel_shipment(&t.buyer, &shipment_id); +} + +#[test] +fn test_cancel_zero_confirmed() { + let t = setup(); + let client = ChainSettleContractClient::new(&t.env, &t.contract_id); + let token_client = token::Client::new(&t.env, &t.token_id); + + let total: i128 = 1_000_000_000; + let before = token_client.balance(&t.buyer); + let shipment_id = String::from_str(&t.env, "CANCEL-ZERO"); + + create_standard_shipment( + &client, + &t.env, + &shipment_id, + &t.buyer, + &t.supplier, + &t.logistics, + &t.arbiter, + &t.token_id, + total, + ); + + client.cancel_shipment(&t.buyer, &shipment_id); + + assert_eq!( + client.get_shipment(&shipment_id).status, + ShipmentStatus::Cancelled + ); + assert_eq!(token_client.balance(&t.buyer), before); +}