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);
+}