diff --git a/contracts/package/cross-contract-validator/Cargo.toml b/contracts/package/cross-contract-validator/Cargo.toml new file mode 100644 index 00000000..06862427 --- /dev/null +++ b/contracts/package/cross-contract-validator/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "cross-contract-validator" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { version = "22.0.0", features = ["alloc"] } + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils"] } diff --git a/contracts/package/cross-contract-validator/src/lib.rs b/contracts/package/cross-contract-validator/src/lib.rs new file mode 100644 index 00000000..355a9e7c --- /dev/null +++ b/contracts/package/cross-contract-validator/src/lib.rs @@ -0,0 +1,206 @@ +#![no_std] +use soroban_sdk::{ + contract, contractimpl, contracttype, + Address, BytesN, Env, Symbol, +}; + +mod shipment_interface { + use soroban_sdk::{contractclient, BytesN, Env}; + #[contractclient(name = "ShipmentClient")] + pub trait ShipmentTrait { + fn get_status(env: Env, shipment_id: BytesN<32>) -> Symbol; + } +} + +mod escrow_interface { + use soroban_sdk::{contractclient, BytesN, Env}; + #[contractclient(name = "EscrowClient")] + pub trait EscrowTrait { + fn get_status(env: Env, escrow_id: BytesN<32>) -> Symbol; + fn release(env: Env, escrow_id: BytesN<32>); + } +} + +use escrow_interface::EscrowClient; +use shipment_interface::ShipmentClient; + +#[contracttype] +enum DataKey { + ShipmentContract, + EscrowContract, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ValidationError { + ShipmentNotDelivered = 1, + EscrowNotFunded = 2, + NotInitialized = 3, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ReleaseResult { + Released, + Blocked(ValidationError), +} + + +#[contract] +pub struct CrossContractValidator; + +#[contractimpl] +impl CrossContractValidator { + pub fn initialize( + env: Env, + shipment_contract: Address, + escrow_contract: Address, + ) { + env.storage().instance().set(&DataKey::ShipmentContract, &shipment_contract); + env.storage().instance().set(&DataKey::EscrowContract, &escrow_contract); + } + + /// Atomically validates shipment delivery and escrow funding before releasing. + /// Returns `ReleaseResult::Blocked(reason)` if either check fails. + pub fn validate_and_release( + env: Env, + shipment_id: BytesN<32>, + escrow_id: BytesN<32>, + ) -> ReleaseResult { + let shipment_addr: Address = match env + .storage() + .instance() + .get(&DataKey::ShipmentContract) + { + Some(a) => a, + None => return ReleaseResult::Blocked(ValidationError::NotInitialized), + }; + + let escrow_addr: Address = match env + .storage() + .instance() + .get(&DataKey::EscrowContract) + { + Some(a) => a, + None => return ReleaseResult::Blocked(ValidationError::NotInitialized), + }; + + // 1. Check shipment status + let shipment_status = + ShipmentClient::new(&env, &shipment_addr).get_status(&shipment_id); + if shipment_status != Symbol::new(&env, "DELIVERED") { + return ReleaseResult::Blocked(ValidationError::ShipmentNotDelivered); + } + + // 2. Check escrow status + let escrow_status = + EscrowClient::new(&env, &escrow_addr).get_status(&escrow_id); + if escrow_status != Symbol::new(&env, "FUNDED") { + return ReleaseResult::Blocked(ValidationError::EscrowNotFunded); + } + + // 3. Both checks passed — release + EscrowClient::new(&env, &escrow_addr).release(&escrow_id); + + ReleaseResult::Released + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{contract, contractimpl, testutils::Address as _, BytesN, Env, Symbol}; + + #[contract] + struct MockShipment; + #[contractimpl] + impl MockShipment { + pub fn get_status(_env: Env, _id: BytesN<32>) -> Symbol { + Symbol::new(&_env, "DELIVERED") + } + } + + #[contract] + struct MockShipmentNotDelivered; + #[contractimpl] + impl MockShipmentNotDelivered { + pub fn get_status(_env: Env, _id: BytesN<32>) -> Symbol { + Symbol::new(&_env, "IN_TRANSIT") + } + } + + #[contract] + struct MockEscrow; + #[contractimpl] + impl MockEscrow { + pub fn get_status(_env: Env, _id: BytesN<32>) -> Symbol { + Symbol::new(&_env, "FUNDED") + } + pub fn release(_env: Env, _id: BytesN<32>) {} + } + + #[contract] + struct MockEscrowNotFunded; + #[contractimpl] + impl MockEscrowNotFunded { + pub fn get_status(_env: Env, _id: BytesN<32>) -> Symbol { + Symbol::new(&_env, "RELEASED") + } + pub fn release(_env: Env, _id: BytesN<32>) {} + } + + fn ids(env: &Env) -> (BytesN<32>, BytesN<32>) { + (BytesN::from_array(env, &[1u8; 32]), BytesN::from_array(env, &[2u8; 32])) + } + + #[test] + fn test_happy_path_releases() { + let env = Env::default(); + env.mock_all_auths(); + + let shipment_id = env.register(MockShipment, ()); + let escrow_id = env.register(MockEscrow, ()); + let validator_id = env.register(CrossContractValidator, ()); + let client = CrossContractValidatorClient::new(&env, &validator_id); + + client.initialize(&shipment_id, &escrow_id); + let (sid, eid) = ids(&env); + assert_eq!(client.validate_and_release(&sid, &eid), ReleaseResult::Released); + } + + #[test] + fn test_shipment_not_delivered_blocked() { + let env = Env::default(); + env.mock_all_auths(); + + let shipment_id = env.register(MockShipmentNotDelivered, ()); + let escrow_id = env.register(MockEscrow, ()); + let validator_id = env.register(CrossContractValidator, ()); + let client = CrossContractValidatorClient::new(&env, &validator_id); + + client.initialize(&shipment_id, &escrow_id); + let (sid, eid) = ids(&env); + assert_eq!( + client.validate_and_release(&sid, &eid), + ReleaseResult::Blocked(ValidationError::ShipmentNotDelivered) + ); + } + + #[test] + fn test_escrow_not_funded_blocked() { + let env = Env::default(); + env.mock_all_auths(); + + let shipment_id = env.register(MockShipment, ()); + let escrow_id = env.register(MockEscrowNotFunded, ()); + let validator_id = env.register(CrossContractValidator, ()); + let client = CrossContractValidatorClient::new(&env, &validator_id); + + client.initialize(&shipment_id, &escrow_id); + let (sid, eid) = ids(&env); + assert_eq!( + client.validate_and_release(&sid, &eid), + ReleaseResult::Blocked(ValidationError::EscrowNotFunded) + ); + } +} diff --git a/contracts/package/escrow-pausable/Cargo.toml b/contracts/package/escrow-pausable/Cargo.toml new file mode 100644 index 00000000..4176ee98 --- /dev/null +++ b/contracts/package/escrow-pausable/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "escrow-pausable" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { version = "22.0.0", features = ["alloc"] } + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils"] } diff --git a/contracts/package/escrow-pausable/src/lib.rs b/contracts/package/escrow-pausable/src/lib.rs new file mode 100644 index 00000000..5f66160c --- /dev/null +++ b/contracts/package/escrow-pausable/src/lib.rs @@ -0,0 +1,250 @@ +#![no_std] +use soroban_sdk::{ + contract, contractimpl, contracttype, + symbol_short, Address, BytesN, Env, Symbol, +}; + +#[contracttype] +enum DataKey { + Admin, + Paused, + Escrow(BytesN<32>), +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum EscrowStatus { + Funded, + Released, + Refunded, + Disputed, +} + +#[contracttype] +#[derive(Clone)] +pub struct EscrowRecord { + pub depositor: Address, + pub amount: i128, + pub status: EscrowStatus, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ContractError { + Unauthorized = 1, + ContractPaused = 2, + EscrowNotFound = 3, + AlreadyFunded = 4, + InvalidState = 5, + AlreadyInitialized = 6, +} + +#[contract] +pub struct EscrowPausableContract; + +#[contractimpl] +impl EscrowPausableContract { + pub fn initialize(env: Env, admin: Address) -> Result<(), ContractError> { + if env.storage().instance().has(&DataKey::Admin) { + return Err(ContractError::AlreadyInitialized); + } + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Paused, &false); + Ok(()) + } + + pub fn pause(env: Env, caller: Address) -> Result<(), ContractError> { + Self::require_admin(&env, &caller)?; + env.storage().instance().set(&DataKey::Paused, &true); + let now = env.ledger().timestamp(); + env.events().publish( + (Symbol::new(&env, "ContractPaused"), caller.clone()), + (caller, now), + ); + Ok(()) + } + + pub fn unpause(env: Env, caller: Address) -> Result<(), ContractError> { + Self::require_admin(&env, &caller)?; + env.storage().instance().set(&DataKey::Paused, &false); + Ok(()) + } + + pub fn is_paused(env: Env) -> bool { + env.storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) + } + + /// Deposit funds — blocked when paused. + pub fn fund_escrow( + env: Env, + escrow_id: BytesN<32>, + depositor: Address, + amount: i128, + ) -> Result<(), ContractError> { + Self::require_not_paused(&env)?; + + if env.storage().persistent().has(&DataKey::Escrow(escrow_id.clone())) { + return Err(ContractError::AlreadyFunded); + } + + let record = EscrowRecord { depositor, amount, status: EscrowStatus::Funded }; + env.storage().persistent().set(&DataKey::Escrow(escrow_id), &record); + Ok(()) + } + + /// Open a dispute — blocked when paused. + pub fn open_dispute( + env: Env, + escrow_id: BytesN<32>, + ) -> Result<(), ContractError> { + Self::require_not_paused(&env)?; + let mut record = Self::get_escrow_record(&env, &escrow_id)?; + if record.status != EscrowStatus::Funded { + return Err(ContractError::InvalidState); + } + record.status = EscrowStatus::Disputed; + env.storage().persistent().set(&DataKey::Escrow(escrow_id), &record); + Ok(()) + } + + /// Release funds to recipient — works even when paused. + pub fn release_funds( + env: Env, + caller: Address, + escrow_id: BytesN<32>, + _recipient: Address, + ) -> Result { + Self::require_admin(&env, &caller)?; + let mut record = Self::get_escrow_record(&env, &escrow_id)?; + if record.status != EscrowStatus::Funded && record.status != EscrowStatus::Disputed { + return Err(ContractError::InvalidState); + } + let amount = record.amount; + record.status = EscrowStatus::Released; + env.storage().persistent().set(&DataKey::Escrow(escrow_id), &record); + Ok(amount) + } + + /// Refund to depositor — works even when paused. + pub fn refund( + env: Env, + caller: Address, + escrow_id: BytesN<32>, + ) -> Result { + Self::require_admin(&env, &caller)?; + let mut record = Self::get_escrow_record(&env, &escrow_id)?; + if record.status != EscrowStatus::Funded && record.status != EscrowStatus::Disputed { + return Err(ContractError::InvalidState); + } + let amount = record.amount; + record.status = EscrowStatus::Refunded; + env.storage().persistent().set(&DataKey::Escrow(escrow_id), &record); + Ok(amount) + } + + fn require_admin(env: &Env, caller: &Address) -> Result<(), ContractError> { + caller.require_auth(); + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(ContractError::Unauthorized)?; + if *caller != admin { + return Err(ContractError::Unauthorized); + } + Ok(()) + } + + fn require_not_paused(env: &Env) -> Result<(), ContractError> { + if env.storage().instance().get(&DataKey::Paused).unwrap_or(false) { + return Err(ContractError::ContractPaused); + } + Ok(()) + } + + fn get_escrow_record(env: &Env, escrow_id: &BytesN<32>) -> Result { + env.storage() + .persistent() + .get(&DataKey::Escrow(escrow_id.clone())) + .ok_or(ContractError::EscrowNotFound) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::Address as _, Address, BytesN, Env}; + + fn setup() -> (Env, EscrowPausableContractClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(EscrowPausableContract, ()); + let client = EscrowPausableContractClient::new(&env, &id); + let admin = Address::generate(&env); + client.initialize(&admin).unwrap(); + (env, client, admin) + } + + fn escrow_id(env: &Env) -> BytesN<32> { BytesN::from_array(env, &[2u8; 32]) } + + #[test] + fn test_fund_fails_when_paused() { + let (env, client, admin) = setup(); + client.pause(&admin).unwrap(); + let depositor = Address::generate(&env); + let err = client.try_fund_escrow(&escrow_id(&env), &depositor, &1000).unwrap_err(); + assert_eq!(err.unwrap(), ContractError::ContractPaused); + } + + #[test] + fn test_dispute_fails_when_paused() { + let (env, client, admin) = setup(); + let depositor = Address::generate(&env); + client.fund_escrow(&escrow_id(&env), &depositor, &1000).unwrap(); + client.pause(&admin).unwrap(); + let err = client.try_open_dispute(&escrow_id(&env)).unwrap_err(); + assert_eq!(err.unwrap(), ContractError::ContractPaused); + } + + #[test] + fn test_release_succeeds_when_paused() { + let (env, client, admin) = setup(); + let depositor = Address::generate(&env); + let recipient = Address::generate(&env); + client.fund_escrow(&escrow_id(&env), &depositor, &1000).unwrap(); + client.pause(&admin).unwrap(); + let amount = client.release_funds(&admin, &escrow_id(&env), &recipient).unwrap(); + assert_eq!(amount, 1000); + } + + #[test] + fn test_refund_succeeds_when_paused() { + let (env, client, admin) = setup(); + let depositor = Address::generate(&env); + client.fund_escrow(&escrow_id(&env), &depositor, &500).unwrap(); + client.pause(&admin).unwrap(); + let amount = client.refund(&admin, &escrow_id(&env)).unwrap(); + assert_eq!(amount, 500); + } + + #[test] + fn test_non_admin_pause_unauthorized() { + let (env, client, _admin) = setup(); + let rogue = Address::generate(&env); + let err = client.try_pause(&rogue).unwrap_err(); + assert_eq!(err.unwrap(), ContractError::Unauthorized); + } + + #[test] + fn test_is_paused_reflects_state() { + let (env, client, admin) = setup(); + assert!(!client.is_paused()); + client.pause(&admin).unwrap(); + assert!(client.is_paused()); + client.unpause(&admin).unwrap(); + assert!(!client.is_paused()); + } +} diff --git a/contracts/package/reputation-decay/Cargo.toml b/contracts/package/reputation-decay/Cargo.toml new file mode 100644 index 00000000..2d2c19ef --- /dev/null +++ b/contracts/package/reputation-decay/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "reputation-decay" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { version = "22.0.0", features = ["alloc"] } + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils"] } diff --git a/contracts/package/reputation-decay/src/lib.rs b/contracts/package/reputation-decay/src/lib.rs new file mode 100644 index 00000000..e0877670 --- /dev/null +++ b/contracts/package/reputation-decay/src/lib.rs @@ -0,0 +1,217 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, Address, BytesN, Env, Vec}; + +#[contracttype] +enum DataKey { + Config, + Ratings(BytesN<32>), // keyed by user_id +} + +#[contracttype] +#[derive(Clone)] +pub struct DecayConfig { + /// Seconds before a rating is considered "recent" (default: 365 days) + pub recent_threshold_secs: u64, + /// Seconds before a rating is considered "old" (default: 730 days) + pub old_threshold_secs: u64, + /// Weight in basis points for recent ratings (default: 10000 = 100%) + pub recent_weight_bps: u32, + /// Weight in basis points for mid-age ratings (default: 7000 = 70%) + pub mid_weight_bps: u32, + /// Weight in basis points for old ratings (default: 4000 = 40%) + pub old_weight_bps: u32, +} + +impl Default for DecayConfig { + fn default() -> Self { + Self { + recent_threshold_secs: 365 * 24 * 3600, + old_threshold_secs: 730 * 24 * 3600, + recent_weight_bps: 10_000, + mid_weight_bps: 7_000, + old_weight_bps: 4_000, + } + } +} + +/// A single rating entry stored on-chain. +#[contracttype] +#[derive(Clone)] +pub struct RatingEntry { + /// Raw score 0–1000 + pub score: u32, + /// Ledger timestamp when the rating was submitted + pub created_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ContractError { + AlreadyInitialized = 1, + NotInitialized = 2, + InvalidScore = 3, + NoRatings = 4, +} + +#[contract] +pub struct ReputationDecayContract; + +#[contractimpl] +impl ReputationDecayContract { + /// One-time initialisation. Pass `None` to use default thresholds. + pub fn initialize(env: Env, config: Option) -> Result<(), ContractError> { + if env.storage().instance().has(&DataKey::Config) { + return Err(ContractError::AlreadyInitialized); + } + let cfg = config.unwrap_or_default(); + env.storage().instance().set(&DataKey::Config, &cfg); + Ok(()) + } + + /// Submit a rating (0–1000) for a user at the current ledger time. + pub fn submit_rating( + env: Env, + user_id: BytesN<32>, + score: u32, + ) -> Result<(), ContractError> { + if score > 1000 { + return Err(ContractError::InvalidScore); + } + let now = env.ledger().timestamp(); + let entry = RatingEntry { score, created_at: now }; + + let mut ratings: Vec = env + .storage() + .persistent() + .get(&DataKey::Ratings(user_id.clone())) + .unwrap_or_else(|| Vec::new(&env)); + + ratings.push_back(entry); + env.storage() + .persistent() + .set(&DataKey::Ratings(user_id), &ratings); + Ok(()) + } + + /// Returns the decay-weighted composite score (0–1000) for a user. + pub fn get_decayed_score( + env: Env, + user_id: BytesN<32>, + current_ledger_time: u64, + ) -> Result { + let cfg: DecayConfig = env + .storage() + .instance() + .get(&DataKey::Config) + .ok_or(ContractError::NotInitialized)?; + + let ratings: Vec = env + .storage() + .persistent() + .get(&DataKey::Ratings(user_id)) + .unwrap_or_else(|| Vec::new(&env)); + + if ratings.is_empty() { + return Err(ContractError::NoRatings); + } + + let mut weighted_sum: u64 = 0; + let mut weight_total: u64 = 0; + + for entry in ratings.iter() { + let age_secs = current_ledger_time.saturating_sub(entry.created_at); + + let weight_bps = if age_secs < cfg.recent_threshold_secs { + cfg.recent_weight_bps + } else if age_secs < cfg.old_threshold_secs { + cfg.mid_weight_bps + } else { + cfg.old_weight_bps + } as u64; + + weighted_sum += entry.score as u64 * weight_bps; + weight_total += weight_bps; + } + + if weight_total == 0 { + return Ok(0); + } + + Ok((weighted_sum / weight_total) as u32) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::Ledger, BytesN, Env}; + + const DAY: u64 = 24 * 3600; + + fn setup() -> (Env, ReputationDecayContractClient<'static>) { + let env = Env::default(); + let id = env.register(ReputationDecayContract, ()); + let client = ReputationDecayContractClient::new(&env, &id); + env.as_contract(&id, || { client.initialize(&None).unwrap(); }); + (env, client) + } + + fn user(env: &Env) -> BytesN<32> { BytesN::from_array(env, &[1u8; 32]) } + + #[test] + fn test_recent_rating_full_weight() { + let (env, client) = setup(); + let u = user(&env); + // Submit rating at t=0, query at t=0 (0 days old → 100% weight) + env.ledger().set_timestamp(0); + env.as_contract(&client.address, || { + ReputationDecayContract::submit_rating(env.clone(), u.clone(), 800).unwrap(); + }); + let score = env.as_contract(&client.address, || { + ReputationDecayContract::get_decayed_score(env.clone(), u.clone(), 0).unwrap() + }); + assert_eq!(score, 800); + } + + #[test] + fn test_mid_age_rating_70_percent() { + let (env, client) = setup(); + let u = user(&env); + // Rating submitted at t=0, queried at t=366 days (mid-age → 70% weight) + env.ledger().set_timestamp(0); + env.as_contract(&client.address, || { + ReputationDecayContract::submit_rating(env.clone(), u.clone(), 1000).unwrap(); + }); + let query_time = 366 * DAY; + let score = env.as_contract(&client.address, || { + ReputationDecayContract::get_decayed_score(env.clone(), u.clone(), query_time).unwrap() + }); + // 1000 * 7000 / 7000 = 1000 (only one rating, weight cancels out) + assert_eq!(score, 1000); + } + + #[test] + fn test_old_rating_40_percent_mixed() { + let (env, client) = setup(); + let u = user(&env); + + // Two ratings: one recent (score=1000), one old at 731 days (score=500) + // Recent: 1000 * 10000 = 10_000_000 + // Old: 500 * 4000 = 2_000_000 + // Total weight: 14000 → weighted avg = 12_000_000 / 14000 = 857 + env.ledger().set_timestamp(0); + env.as_contract(&client.address, || { + ReputationDecayContract::submit_rating(env.clone(), u.clone(), 500).unwrap(); + }); + let recent_time = 731 * DAY + 1; + env.ledger().set_timestamp(recent_time); + env.as_contract(&client.address, || { + ReputationDecayContract::submit_rating(env.clone(), u.clone(), 1000).unwrap(); + }); + + let score = env.as_contract(&client.address, || { + ReputationDecayContract::get_decayed_score(env.clone(), u.clone(), recent_time).unwrap() + }); + assert_eq!(score, 857); + } +} diff --git a/contracts/package/shipment-events/Cargo.toml b/contracts/package/shipment-events/Cargo.toml new file mode 100644 index 00000000..ab5c1581 --- /dev/null +++ b/contracts/package/shipment-events/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "shipment-events" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { version = "22.0.0", features = ["alloc"] } + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils"] } diff --git a/contracts/package/shipment-events/src/lib.rs b/contracts/package/shipment-events/src/lib.rs new file mode 100644 index 00000000..45c175ce --- /dev/null +++ b/contracts/package/shipment-events/src/lib.rs @@ -0,0 +1,262 @@ +#![no_std] +use soroban_sdk::{ + contract, contractimpl, contracttype, + Address, BytesN, Env, Symbol, +}; + +#[contracttype] +enum DataKey { + Shipment(BytesN<32>), +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ShipmentStatus { + Created, + Accepted, + InTransit, + Delivered, + Completed, + Disputed, + Cancelled, +} + +#[contracttype] +#[derive(Clone)] +pub struct ShipmentRecord { + pub shipper: Address, + pub carrier: Address, + pub status: ShipmentStatus, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ContractError { + ShipmentNotFound = 1, + InvalidTransition = 2, + AlreadyExists = 3, +} + +fn emit( + env: &Env, + event_name: &str, + shipment_id: &BytesN<32>, + new_status: ShipmentStatus, + actor: &Address, +) { + let topic = (Symbol::new(env, event_name), shipment_id.clone()); + let payload = (shipment_id.clone(), new_status, actor.clone(), env.ledger().timestamp()); + env.events().publish(topic, payload); +} + +#[contract] +pub struct ShipmentEventsContract; + +#[contractimpl] +impl ShipmentEventsContract { + pub fn create_shipment( + env: Env, + shipment_id: BytesN<32>, + shipper: Address, + carrier: Address, + ) -> Result<(), ContractError> { + if env.storage().persistent().has(&DataKey::Shipment(shipment_id.clone())) { + return Err(ContractError::AlreadyExists); + } + let record = ShipmentRecord { shipper: shipper.clone(), carrier, status: ShipmentStatus::Created }; + env.storage().persistent().set(&DataKey::Shipment(shipment_id.clone()), &record); + emit(&env, "ShipmentCreated", &shipment_id, ShipmentStatus::Created, &shipper); + Ok(()) + } + + pub fn accept_shipment( + env: Env, + shipment_id: BytesN<32>, + actor: Address, + ) -> Result<(), ContractError> { + let mut r = Self::load(&env, &shipment_id)?; + if r.status != ShipmentStatus::Created { return Err(ContractError::InvalidTransition); } + r.status = ShipmentStatus::Accepted; + env.storage().persistent().set(&DataKey::Shipment(shipment_id.clone()), &r); + emit(&env, "ShipmentAccepted", &shipment_id, ShipmentStatus::Accepted, &actor); + Ok(()) + } + + pub fn mark_in_transit( + env: Env, + shipment_id: BytesN<32>, + actor: Address, + ) -> Result<(), ContractError> { + let mut r = Self::load(&env, &shipment_id)?; + if r.status != ShipmentStatus::Accepted { return Err(ContractError::InvalidTransition); } + r.status = ShipmentStatus::InTransit; + env.storage().persistent().set(&DataKey::Shipment(shipment_id.clone()), &r); + emit(&env, "InTransit", &shipment_id, ShipmentStatus::InTransit, &actor); + Ok(()) + } + + pub fn mark_delivered( + env: Env, + shipment_id: BytesN<32>, + actor: Address, + ) -> Result<(), ContractError> { + let mut r = Self::load(&env, &shipment_id)?; + if r.status != ShipmentStatus::InTransit { return Err(ContractError::InvalidTransition); } + r.status = ShipmentStatus::Delivered; + env.storage().persistent().set(&DataKey::Shipment(shipment_id.clone()), &r); + emit(&env, "Delivered", &shipment_id, ShipmentStatus::Delivered, &actor); + Ok(()) + } + + pub fn complete_shipment( + env: Env, + shipment_id: BytesN<32>, + actor: Address, + ) -> Result<(), ContractError> { + let mut r = Self::load(&env, &shipment_id)?; + if r.status != ShipmentStatus::Delivered { return Err(ContractError::InvalidTransition); } + r.status = ShipmentStatus::Completed; + env.storage().persistent().set(&DataKey::Shipment(shipment_id.clone()), &r); + emit(&env, "Completed", &shipment_id, ShipmentStatus::Completed, &actor); + Ok(()) + } + + pub fn open_dispute( + env: Env, + shipment_id: BytesN<32>, + actor: Address, + ) -> Result<(), ContractError> { + let mut r = Self::load(&env, &shipment_id)?; + if r.status != ShipmentStatus::Delivered && r.status != ShipmentStatus::InTransit { + return Err(ContractError::InvalidTransition); + } + r.status = ShipmentStatus::Disputed; + env.storage().persistent().set(&DataKey::Shipment(shipment_id.clone()), &r); + emit(&env, "Disputed", &shipment_id, ShipmentStatus::Disputed, &actor); + Ok(()) + } + + pub fn cancel_shipment( + env: Env, + shipment_id: BytesN<32>, + actor: Address, + ) -> Result<(), ContractError> { + let mut r = Self::load(&env, &shipment_id)?; + if r.status == ShipmentStatus::Completed || r.status == ShipmentStatus::Cancelled { + return Err(ContractError::InvalidTransition); + } + r.status = ShipmentStatus::Cancelled; + env.storage().persistent().set(&DataKey::Shipment(shipment_id.clone()), &r); + emit(&env, "Cancelled", &shipment_id, ShipmentStatus::Cancelled, &actor); + Ok(()) + } + + fn load(env: &Env, shipment_id: &BytesN<32>) -> Result { + env.storage() + .persistent() + .get(&DataKey::Shipment(shipment_id.clone())) + .ok_or(ContractError::ShipmentNotFound) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{ + testutils::{Address as _, Events}, + Address, BytesN, Env, IntoVal, Symbol, + }; + + fn setup() -> (Env, ShipmentEventsContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(ShipmentEventsContract, ()); + let client = ShipmentEventsContractClient::new(&env, &id); + (env, client) + } + + fn sid(env: &Env) -> BytesN<32> { BytesN::from_array(env, &[9u8; 32]) } + + fn assert_event(env: &Env, expected_name: &str, shipment_id: &BytesN<32>) { + let events = env.events().all(); + let found = events.iter().any(|(_, topics, _)| { + // topics is a Vec; first topic is the Symbol + topics.iter().any(|t| { + t == Symbol::new(env, expected_name).into_val(env) + }) + }); + assert!(found, "event '{}' not found in event log", expected_name); + } + + #[test] + fn test_create_emits_event() { + let (env, client) = setup(); + let shipper = Address::generate(&env); + let carrier = Address::generate(&env); + client.create_shipment(&sid(&env), &shipper, &carrier).unwrap(); + assert_event(&env, "ShipmentCreated", &sid(&env)); + } + + #[test] + fn test_accept_emits_event() { + let (env, client) = setup(); + let shipper = Address::generate(&env); + let carrier = Address::generate(&env); + client.create_shipment(&sid(&env), &shipper, &carrier).unwrap(); + client.accept_shipment(&sid(&env), &carrier).unwrap(); + assert_event(&env, "ShipmentAccepted", &sid(&env)); + } + + #[test] + fn test_full_happy_path_events() { + let (env, client) = setup(); + let shipper = Address::generate(&env); + let carrier = Address::generate(&env); + let s = sid(&env); + client.create_shipment(&s, &shipper, &carrier).unwrap(); + client.accept_shipment(&s, &carrier).unwrap(); + client.mark_in_transit(&s, &carrier).unwrap(); + client.mark_delivered(&s, &carrier).unwrap(); + client.complete_shipment(&s, &shipper).unwrap(); + + for name in ["ShipmentCreated", "ShipmentAccepted", "InTransit", "Delivered", "Completed"] { + assert_event(&env, name, &s); + } + } + + #[test] + fn test_dispute_emits_event() { + let (env, client) = setup(); + let shipper = Address::generate(&env); + let carrier = Address::generate(&env); + let s = sid(&env); + client.create_shipment(&s, &shipper, &carrier).unwrap(); + client.accept_shipment(&s, &carrier).unwrap(); + client.mark_in_transit(&s, &carrier).unwrap(); + client.open_dispute(&s, &shipper).unwrap(); + assert_event(&env, "Disputed", &s); + } + + #[test] + fn test_cancel_emits_event() { + let (env, client) = setup(); + let shipper = Address::generate(&env); + let carrier = Address::generate(&env); + let s = sid(&env); + client.create_shipment(&s, &shipper, &carrier).unwrap(); + client.cancel_shipment(&s, &shipper).unwrap(); + assert_event(&env, "Cancelled", &s); + } + + #[test] + fn test_invalid_transition_rejected() { + let (env, client) = setup(); + let shipper = Address::generate(&env); + let carrier = Address::generate(&env); + let s = sid(&env); + client.create_shipment(&s, &shipper, &carrier).unwrap(); + // Can't go from Created → Delivered directly + let err = client.try_mark_delivered(&s, &carrier).unwrap_err(); + assert_eq!(err.unwrap(), ContractError::InvalidTransition); + } +}