diff --git a/contracts/chainsetttle/src/lib.rs b/contracts/chainsetttle/src/lib.rs index 6ca8dd7..f77c6a9 100644 --- a/contracts/chainsetttle/src/lib.rs +++ b/contracts/chainsetttle/src/lib.rs @@ -1,9 +1,41 @@ #![no_std] use soroban_sdk::{ - contract, contractimpl, contracttype, contracterror, token, Address, BytesN, Env, String, Vec, Symbol, + contract, contractclient, contractimpl, contracttype, token, Address, BytesN, Env, String, Vec, Symbol, }; +// ============================================================ +// YIELD PROTOCOL INTERFACE +// ============================================================ + +mod yield_protocol { + use soroban_sdk::{contractclient, Address, Env}; + + /// Minimal interface expected of an external yield protocol. + /// The ChainSettle contract deposits idle escrow tokens here to earn + /// yield while milestones are pending confirmation. + /// + /// Deposit flow: + /// 1. ChainSettle transfers `amount` tokens to the protocol contract. + /// 2. ChainSettle calls `deposit` so the protocol records the principal. + /// + /// Withdraw flow: + /// 1. ChainSettle calls `withdraw`; the protocol transfers principal + + /// accrued yield back to `to` and returns the total amount. + #[contractclient(name = "YieldProtocolClient")] + pub trait YieldProtocol { + /// Record a deposit of `amount` units of `token` on behalf of `depositor`. + /// The caller must have already transferred the tokens to this contract. + fn deposit(env: Env, depositor: Address, token: Address, amount: i128); + /// Withdraw all funds (principal + yield) for `depositor`/`token` to `to`. + /// Returns the total amount transferred. + fn withdraw(env: Env, depositor: Address, token: Address, to: Address) -> i128; + /// Current balance (principal + accrued yield) for `depositor` and `token`. + fn balance_of(env: Env, depositor: Address, token: Address) -> i128; + } +} +use yield_protocol::YieldProtocolClient; + // ============================================================ // DATA TYPES // ============================================================ @@ -179,6 +211,22 @@ pub struct ShipmentOptions { pub expires_at_ledger: Option, } +/// All parameters needed to create a single shipment in a batch call. +/// Mirrors the individual `create_shipment` parameters without the `Env`. +#[contracttype] +#[derive(Clone)] +pub struct BatchShipmentParams { + pub shipment_id: String, + pub buyers: Vec
, + pub supplier: Address, + pub logistics: Address, + pub arbiter: Address, + pub token: Address, + pub total_amount: i128, + pub milestones: Vec, + pub options: ShipmentOptions, +} + /// Contract-level statistics for analytics and monitoring. #[contracttype] #[derive(Clone)] @@ -311,6 +359,11 @@ pub enum DataKey { /// Contested percentage stored when a partial dispute is raised: (shipment_id, milestone_index) -> u32. /// Absence of this key means the associated dispute covers 100% of the milestone value. DisputeContestedPercent(String, u32), + /// Address of the external yield protocol contract (admin-configured; optional). + YieldProtocol, + /// Cumulative amount deposited to the yield protocol per token address. + /// Cleared to 0 on each full withdrawal. + YieldDeposited(Address), /// Supplier collateral amount for a shipment. SupplierCollateral(String), } @@ -3591,6 +3644,214 @@ impl ChainSettleContract { seen.len() as u32 } + // ---------------------------------------------------------- + // YIELD PROTOCOL INTEGRATION + // ---------------------------------------------------------- + + /// Configure the external yield protocol contract address. Admin only. + /// Once set, `deposit_idle_to_yield` and `withdraw_from_yield` become callable. + pub fn set_yield_protocol(env: Env, admin: Address, protocol: Address) { + admin.require_auth(); + Self::assert_admin(&env, &admin); + env.storage() + .instance() + .set(&DataKey::YieldProtocol, &protocol); + env.events() + .publish((Symbol::new(&env, "yield_protocol_set"),), protocol); + } + + /// Deposit `amount` idle escrow tokens for `token` into the external yield protocol. + /// + /// The admin is responsible for ensuring the contract retains sufficient + /// liquidity to cover upcoming milestone payments; no automatic check is + /// enforced here beyond "don't deposit more than un-deposited escrow balance". + /// + /// Admin only. + pub fn deposit_idle_to_yield(env: Env, admin: Address, token: Address, amount: i128) { + Self::assert_not_paused(&env); + admin.require_auth(); + Self::assert_admin(&env, &admin); + + if amount <= 0 { + panic!("amount must be greater than zero"); + } + + let protocol: Address = env + .storage() + .instance() + .get(&DataKey::YieldProtocol) + .unwrap_or_else(|| panic!("yield protocol not configured")); + + let total_escrowed: i128 = env + .storage() + .persistent() + .get(&DataKey::TotalEscrowed(token.clone())) + .unwrap_or(0); + + let already_deposited: i128 = env + .storage() + .persistent() + .get(&DataKey::YieldDeposited(token.clone())) + .unwrap_or(0); + + let available = total_escrowed - already_deposited; + if amount > available { + panic!("amount exceeds available idle escrow balance"); + } + + // Push tokens to the yield protocol, then record the deposit. + let token_client = token::Client::new(&env, &token); + token_client.transfer(&env.current_contract_address(), &protocol, &amount); + + let yield_client = YieldProtocolClient::new(&env, &protocol); + yield_client.deposit(&env.current_contract_address(), &token, &amount); + + let new_deposited = already_deposited + amount; + env.storage() + .persistent() + .set(&DataKey::YieldDeposited(token.clone()), &new_deposited); + + env.events().publish( + (Symbol::new(&env, "yield_deposited"),), + (token, amount, new_deposited), + ); + } + + /// Withdraw all deposited funds (principal + accrued yield) from the external + /// yield protocol back to this contract. + /// + /// Any yield earned above the deposited principal is added to `TotalEscrowed` + /// so that it can be distributed to suppliers via normal milestone payments. + /// + /// Returns the total amount received from the protocol (principal + yield). + /// Admin only. + pub fn withdraw_from_yield(env: Env, admin: Address, token: Address) -> i128 { + Self::assert_not_paused(&env); + admin.require_auth(); + Self::assert_admin(&env, &admin); + + let protocol: Address = env + .storage() + .instance() + .get(&DataKey::YieldProtocol) + .unwrap_or_else(|| panic!("yield protocol not configured")); + + let deposited: i128 = env + .storage() + .persistent() + .get(&DataKey::YieldDeposited(token.clone())) + .unwrap_or(0); + + if deposited == 0 { + panic!("no yield deposit to withdraw"); + } + + let yield_client = YieldProtocolClient::new(&env, &protocol); + let withdrawn = yield_client.withdraw( + &env.current_contract_address(), + &token, + &env.current_contract_address(), + ); + + // Any amount above the original principal is accrued yield — add it to + // TotalEscrowed so escrow accounting stays correct. + let yield_earned = if withdrawn > deposited { + withdrawn - deposited + } else { + 0 + }; + + if yield_earned > 0 { + let current_escrowed: i128 = env + .storage() + .persistent() + .get(&DataKey::TotalEscrowed(token.clone())) + .unwrap_or(0); + env.storage().persistent().set( + &DataKey::TotalEscrowed(token.clone()), + &(current_escrowed + yield_earned), + ); + } + + env.storage() + .persistent() + .set(&DataKey::YieldDeposited(token.clone()), &0i128); + + env.events().publish( + (Symbol::new(&env, "yield_withdrawn"),), + (token, withdrawn, yield_earned), + ); + + withdrawn + } + + /// Returns the amount currently tracked as deposited to the yield protocol for `token`. + pub fn get_yield_deposited(env: Env, token: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::YieldDeposited(token)) + .unwrap_or(0) + } + + /// Query the live balance (principal + accrued yield) directly from the + /// external yield protocol. Returns 0 if no protocol is configured. + pub fn get_yield_balance(env: Env, token: Address) -> i128 { + if let Some(protocol) = env + .storage() + .instance() + .get::(&DataKey::YieldProtocol) + { + let yield_client = YieldProtocolClient::new(&env, &protocol); + yield_client.balance_of(&env.current_contract_address(), &token) + } else { + 0 + } + } + + // ---------------------------------------------------------- + // BATCH CREATE SHIPMENTS + // ---------------------------------------------------------- + + /// Create multiple independent shipments in a single atomic transaction. + /// + /// Every entry in `params` is validated and created in order. Because + /// Soroban transactions are atomic, any validation failure reverts all + /// preceding creations in the batch. + /// + /// Returns the ordered list of created shipment IDs. + pub fn batch_create_shipments( + env: Env, + params: Vec, + ) -> Vec { + Self::assert_not_paused(&env); + + let mut created: Vec = Vec::new(&env); + + for i in 0..params.len() { + let p = params.get(i).unwrap(); + let shipment_id = Self::create_shipment( + env.clone(), + p.shipment_id, + p.buyers, + p.supplier, + p.logistics, + p.arbiter, + p.token, + p.total_amount, + p.milestones, + p.options, + ); + created.push_back(shipment_id); + } + + env.events().publish( + (Symbol::new(&env, "batch_shipments_created"),), + created.len() as u32, + ); + + created + } + // ---------------------------------------------------------- // INTERNAL HELPERS // ---------------------------------------------------------- diff --git a/contracts/chainsetttle/src/property_tests.rs b/contracts/chainsetttle/src/property_tests.rs index 672eb43..a917230 100644 --- a/contracts/chainsetttle/src/property_tests.rs +++ b/contracts/chainsetttle/src/property_tests.rs @@ -397,3 +397,318 @@ mod contract_prop_tests { } } } + +// ============================================================ +// MILESTONE PERCENTAGE FUZZ TESTS +// +// Pure-Rust model: validates the percentage constraint logic that +// the contract enforces in `create_shipment` and `rebalance_milestones`. +// +// Rules under test: +// 1. sum(percents) must equal 100 +// 2. every individual percent must be >= MIN_PERCENT (default 5) +// ============================================================ + +#[cfg(test)] +mod milestone_percent_fuzz { + use proptest::prelude::*; + + const MIN_PERCENT: u32 = 5; + + fn is_valid_percent_set(percents: &[u32]) -> bool { + if percents.is_empty() { + return false; + } + let sum: u32 = percents.iter().sum(); + sum == 100 && percents.iter().all(|&p| p >= MIN_PERCENT) + } + + proptest! { + #![proptest_config(proptest::test_runner::Config { + cases: 10_000, + ..Default::default() + })] + + /// Validity is determined solely by (sum == 100) AND (all >= MIN_PERCENT). + #[test] + fn prop_percent_validity_is_sum_and_min_only( + percents in prop::collection::vec(0u32..=100u32, 1..=10usize), + ) { + let sum: u32 = percents.iter().sum(); + let all_above_min = percents.iter().all(|&p| p >= MIN_PERCENT); + let model_valid = sum == 100 && all_above_min; + prop_assert_eq!( + is_valid_percent_set(&percents), + model_valid, + "validity mismatch for {:?} (sum={}, all_above_min={})", + percents, sum, all_above_min + ); + } + + /// A two-element split (first, 100-first) is valid iff both halves >= MIN_PERCENT. + #[test] + fn prop_two_milestone_split_validity(first in 0u32..=100u32) { + let second = 100u32.saturating_sub(first); + let percents = [first, second]; + let expected = first >= MIN_PERCENT && second >= MIN_PERCENT; + prop_assert_eq!( + is_valid_percent_set(&percents), + expected, + "two-split ({}, {}) validity mismatch", first, second + ); + } + + /// Swapping any two entries does not change validity (order-independent). + #[test] + fn prop_validity_is_order_independent( + percents in prop::collection::vec(5u32..=50u32, 2..=8usize), + a in 0usize..8, + b in 0usize..8, + ) { + let n = percents.len(); + let mut reordered = percents.clone(); + reordered.swap(a % n, b % n); + prop_assert_eq!( + is_valid_percent_set(&percents), + is_valid_percent_set(&reordered), + "swap must not change validity" + ); + } + + /// Any set containing a zero-valued entry is always invalid (0 < MIN_PERCENT). + #[test] + fn prop_zero_percent_entry_always_invalid( + rest in prop::collection::vec(5u32..=50u32, 1..=9usize), + ) { + let mut percents = rest; + percents.push(0); + prop_assert!( + !is_valid_percent_set(&percents), + "set with a 0-valued entry must be invalid" + ); + } + + /// A set with any entry > 100 cannot sum to exactly 100 with positive siblings. + #[test] + fn prop_entry_over_100_is_invalid( + excess in 101u32..=200u32, + ) { + let sum = excess; + prop_assert!( + sum != 100, + "single-entry sum {} must not equal 100", sum + ); + } + + /// Adding a positive delta to a sum-100 set breaks the invariant. + #[test] + fn prop_augmented_sum_invalidates_set( + first in 5u32..=90u32, + delta in 1u32..=10u32, + ) { + let second = 100u32.saturating_sub(first); + if second < MIN_PERCENT { + return Ok(()); + } + // (first, second) is a valid baseline. + prop_assert!(is_valid_percent_set(&[first, second])); + // Augmenting the first entry pushes the sum past 100. + prop_assert!( + !is_valid_percent_set(&[first + delta, second]), + "augmented set (sum={}) must be invalid", first + delta + second + ); + } + + /// For n milestones, the "all equal, with remainder on last" distribution + /// is valid iff every per-entry value >= MIN_PERCENT. + #[test] + fn prop_uniform_milestone_distribution(n in 1usize..=20usize) { + let per: u32 = 100 / n as u32; + let remainder = 100 % n as u32; + let percents: Vec = (0..n) + .map(|i| if i == n - 1 { per + remainder } else { per }) + .collect(); + let sum: u32 = percents.iter().sum(); + prop_assert_eq!(sum, 100u32, "uniform distribution must sum to 100 for n={}", n); + let all_above_min = percents.iter().all(|&p| p >= MIN_PERCENT); + prop_assert_eq!( + is_valid_percent_set(&percents), + all_above_min, + "uniform n={} validity should match all_above_min={}", n, all_above_min + ); + } + + /// An empty list is always invalid (sum = 0 ≠ 100). + #[test] + fn prop_empty_list_is_invalid(_seed in 0u32..1u32) { + let empty: Vec = vec![]; + prop_assert!(!is_valid_percent_set(&empty)); + } + + /// Splitting 100 into exactly MIN_PERCENT-sized blocks leaves + /// 100 / MIN_PERCENT = 20 milestones, each at exactly 5 — valid. + #[test] + fn prop_minimum_percent_exact_split(_seed in 0u32..1u32) { + let n = (100 / MIN_PERCENT) as usize; // 20 + let percents: Vec = (0..n).map(|_| MIN_PERCENT).collect(); + prop_assert!(is_valid_percent_set(&percents)); + } + } + + // -------------------------------------------------------- + // CONTRACT-BACKED PERCENTAGE VALIDATION TESTS + // These spin up a real Soroban test environment and call + // the contract to verify the on-chain checks match the model. + // -------------------------------------------------------- + #[cfg(test)] + mod contract_percent_tests { + use crate::{ + ChainSettleContract, ChainSettleContractClient, Milestone, MilestoneMode, + MilestoneStatus, ShipmentOptions, + }; + use proptest::prelude::*; + use soroban_sdk::{testutils::Address as _, token, vec, Address, Env, String}; + + fn make_test_env() -> (Env, Address, Address, Address, Address, Address, Address) { + 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 buyer = Address::generate(&env); + let supplier = Address::generate(&env); + let logistics = Address::generate(&env); + let arbiter = Address::generate(&env); + token::StellarAssetClient::new(&env, &token_id) + .mint(&buyer, &100_000_000_000i128); + ChainSettleContractClient::new(&env, &contract_id).init(&buyer); + (env, contract_id, token_id, buyer, supplier, logistics, arbiter) + } + + fn default_options() -> 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, + } + } + + fn milestone(env: &Env, name: &str, pct: u32) -> Milestone { + Milestone { + name: String::from_str(env, name), + payment_percent: pct, + proof_hash: String::from_str(env, ""), + status: MilestoneStatus::Pending, + release_after_ledger: 0, + proof_submitted_ledger: None, + dispute_opened_ledger: None, + } + } + + proptest! { + #![proptest_config(proptest::test_runner::Config { + cases: 200, + ..Default::default() + })] + + /// Valid two-milestone splits (both >= 5, sum == 100) are accepted by the contract. + #[test] + fn prop_contract_accepts_valid_two_split(first in 5u32..=90u32) { + let second = 100u32 - first; + if second < 5 { + return Ok(()); + } + let (env, cid, tid, buyer, supplier, logistics, arbiter) = make_test_env(); + let client = ChainSettleContractClient::new(&env, &cid); + let sid = String::from_str(&env, "PCT-VALID"); + let ms = vec![&env, milestone(&env, "M0", first), milestone(&env, "M1", second)]; + let result = client.try_create_shipment( + &sid, + &vec![&env, buyer.clone()], + &supplier, &logistics, &arbiter, &tid, + &1_000_000i128, + &ms, + &default_options(), + ); + prop_assert!( + result.is_ok(), + "valid split ({}, {}) must be accepted", first, second + ); + } + + /// Any milestone percentage below 5 (the default minimum) is rejected. + #[test] + fn prop_contract_rejects_below_min_percent(below in 1u32..=4u32) { + let (env, cid, tid, buyer, supplier, logistics, arbiter) = make_test_env(); + let client = ChainSettleContractClient::new(&env, &cid); + let sid = String::from_str(&env, "PCT-LOW"); + let above = 100 - below; + let ms = vec![&env, milestone(&env, "M0", below), milestone(&env, "M1", above)]; + let result = client.try_create_shipment( + &sid, + &vec![&env, buyer.clone()], + &supplier, &logistics, &arbiter, &tid, + &1_000_000i128, + &ms, + &default_options(), + ); + prop_assert!( + result.is_err(), + "percent {} below minimum must be rejected", below + ); + } + + /// Two-milestone sets whose percentages sum to something other than 100 are rejected. + #[test] + fn prop_contract_rejects_non_100_sum( + first in 5u32..=45u32, + second in 5u32..=45u32, + ) { + let sum = first + second; + if sum == 100 { return Ok(()); } + let (env, cid, tid, buyer, supplier, logistics, arbiter) = make_test_env(); + let client = ChainSettleContractClient::new(&env, &cid); + let sid = String::from_str(&env, "PCT-BADSUM"); + let ms = vec![&env, milestone(&env, "M0", first), milestone(&env, "M1", second)]; + let result = client.try_create_shipment( + &sid, + &vec![&env, buyer.clone()], + &supplier, &logistics, &arbiter, &tid, + &1_000_000i128, + &ms, + &default_options(), + ); + prop_assert!( + result.is_err(), + "sum {} != 100 must be rejected", sum + ); + } + + /// A single 100% milestone (at or above the minimum) is always valid. + #[test] + fn prop_contract_accepts_single_100_milestone(_seed in 0u32..10u32) { + let (env, cid, tid, buyer, supplier, logistics, arbiter) = make_test_env(); + let client = ChainSettleContractClient::new(&env, &cid); + let sid = String::from_str(&env, "PCT-SINGLE"); + let ms = vec![&env, milestone(&env, "M0", 100)]; + let result = client.try_create_shipment( + &sid, + &vec![&env, buyer.clone()], + &supplier, &logistics, &arbiter, &tid, + &1_000_000i128, + &ms, + &default_options(), + ); + prop_assert!(result.is_ok(), "single 100% milestone must be accepted"); + } + } + } +}