diff --git a/contracts/package/Cargo.toml b/contracts/package/Cargo.toml index a417e116..e10b1ed4 100644 --- a/contracts/package/Cargo.toml +++ b/contracts/package/Cargo.toml @@ -1,6 +1,18 @@ +[workspace] +resolver = "2" members = [ - # ... existing members ... - "document-verification", - "certification-registry", - "tests/integration", -] \ No newline at end of file + "document-registry", + "shipment-insurance", + "document-bulk", + "tests/reputation", +] + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true diff --git a/contracts/package/document-bulk/Cargo.toml b/contracts/package/document-bulk/Cargo.toml new file mode 100644 index 00000000..460a4602 --- /dev/null +++ b/contracts/package/document-bulk/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "document-bulk" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[features] +testutils = ["soroban-sdk/testutils"] + +[dependencies] +soroban-sdk = { version = "22.0.0", features = ["alloc"] } + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils", "alloc"] } + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true diff --git a/contracts/package/document-bulk/src/lib.rs b/contracts/package/document-bulk/src/lib.rs new file mode 100644 index 00000000..b1621499 --- /dev/null +++ b/contracts/package/document-bulk/src/lib.rs @@ -0,0 +1,224 @@ +#![no_std] + +//! Bulk Document Registration Contract (CT-12) +//! +//! Registers multiple documents atomically in a single transaction. +//! Maximum 10 documents per batch. Fails entirely if any document is invalid. + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, BytesN, Env, String, Vec, +}; + +// ── Errors ──────────────────────────────────────────────────────────────────── + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum BulkDocumentError { + NotInitialized = 1, + AlreadyInitialized = 2, + BatchLimitExceeded = 3, + HashAlreadyRegistered = 4, +} + +// ── Types ───────────────────────────────────────────────────────────────────── + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct DocumentEntry { + pub hash: BytesN<32>, + pub doc_type: String, + pub ipfs_cid: String, + pub expires_at: Option, +} + +#[contracttype] +pub enum DataKey { + Counter, + Hash(BytesN<32>), // tracks registered hashes for uniqueness +} + +const MAX_BATCH: u32 = 10; +const TTL_LEDGERS: u32 = 6_307_200; + +// ── Contract ────────────────────────────────────────────────────────────────── + +#[contract] +pub struct DocumentBulkContract; + +#[contractimpl] +impl DocumentBulkContract { + pub fn initialize(env: Env) -> Result<(), BulkDocumentError> { + if env.storage().persistent().has(&DataKey::Counter) { + return Err(BulkDocumentError::AlreadyInitialized); + } + env.storage().persistent().set(&DataKey::Counter, &0u64); + Ok(()) + } + + /// Register multiple documents atomically. + /// + /// - Max 10 documents per batch; exceeding returns `BatchLimitExceeded`. + /// - Any duplicate hash returns `HashAlreadyRegistered` and rolls back the entire batch. + /// - Returns the list of document IDs created. + pub fn register_documents_batch( + env: Env, + docs: Vec, + ) -> Result>, BulkDocumentError> { + if docs.len() > MAX_BATCH { + return Err(BulkDocumentError::BatchLimitExceeded); + } + + // Validate all docs before writing anything (atomicity). + // Check against already-registered hashes AND within-batch duplicates. + let mut batch_hashes: Vec> = Vec::new(&env); + for i in 0..docs.len() { + let entry = docs.get(i).unwrap(); + // Already on-chain? + if env + .storage() + .persistent() + .has(&DataKey::Hash(entry.hash.clone())) + { + return Err(BulkDocumentError::HashAlreadyRegistered); + } + // Duplicate within this batch? + if batch_hashes.contains(&entry.hash) { + return Err(BulkDocumentError::HashAlreadyRegistered); + } + batch_hashes.push_back(entry.hash.clone()); + } + + // All valid — commit. + let mut ids: Vec> = Vec::new(&env); + for i in 0..docs.len() { + let entry = docs.get(i).unwrap(); + env.storage() + .persistent() + .set(&DataKey::Hash(entry.hash.clone()), &true); + env.storage().persistent().extend_ttl( + &DataKey::Hash(entry.hash.clone()), + TTL_LEDGERS, + TTL_LEDGERS, + ); + ids.push_back(entry.hash); + } + + Ok(ids) + } + + /// Returns true if the given hash has been registered. + pub fn is_registered(env: Env, hash: BytesN<32>) -> bool { + env.storage() + .persistent() + .has(&DataKey::Hash(hash)) + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{ + testutils::BytesN as _, + BytesN, Env, String, Vec, + }; + + fn setup() -> (Env, DocumentBulkContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register(DocumentBulkContract {}, ()); + let client = DocumentBulkContractClient::new(&env, &id); + client.initialize(); + (env, client) + } + + fn make_entry(env: &Env, hash: BytesN<32>) -> DocumentEntry { + DocumentEntry { + hash, + doc_type: String::from_str(env, "BillOfLading"), + ipfs_cid: String::from_str(env, "QmFake"), + expires_at: None, + } + } + + fn make_docs(env: &Env, n: u32) -> Vec { + let mut docs = Vec::new(env); + for _ in 0..n { + docs.push_back(make_entry(env, BytesN::random(env))); + } + docs + } + + #[test] + fn test_valid_batch_of_5() { + let (env, client) = setup(); + let docs = make_docs(&env, 5); + let ids = client.register_documents_batch(&docs); + assert_eq!(ids.len(), 5); + // Each returned ID should be registered + for i in 0..ids.len() { + assert!(client.is_registered(&ids.get(i).unwrap())); + } + } + + #[test] + fn test_batch_exceeding_10_returns_error() { + let (env, client) = setup(); + let docs = make_docs(&env, 11); + let result = client.try_register_documents_batch(&docs); + assert_eq!(result, Err(Ok(BulkDocumentError::BatchLimitExceeded))); + } + + #[test] + fn test_duplicate_hash_causes_full_rollback() { + let (env, client) = setup(); + let hash = BytesN::random(&env); + + // First register the hash alone + let single = { + let mut v = Vec::new(&env); + v.push_back(make_entry(&env, hash.clone())); + v + }; + client.register_documents_batch(&single); + + // Now try a batch that includes the already-registered hash + let mut docs = make_docs(&env, 4); + docs.push_back(make_entry(&env, hash.clone())); // duplicate + + let result = client.try_register_documents_batch(&docs); + assert_eq!(result, Err(Ok(BulkDocumentError::HashAlreadyRegistered))); + + // The 4 new hashes from the failed batch must NOT be registered + for i in 0..4 { + assert!(!client.is_registered(&docs.get(i).unwrap().hash)); + } + } + + #[test] + fn test_within_batch_duplicate_causes_rollback() { + let (env, client) = setup(); + let dup_hash = BytesN::random(&env); + + let mut docs = Vec::new(&env); + docs.push_back(make_entry(&env, BytesN::random(&env))); + docs.push_back(make_entry(&env, dup_hash.clone())); + docs.push_back(make_entry(&env, dup_hash.clone())); // duplicate within batch + + let result = client.try_register_documents_batch(&docs); + assert_eq!(result, Err(Ok(BulkDocumentError::HashAlreadyRegistered))); + + // Nothing should be registered + assert!(!client.is_registered(&dup_hash)); + } + + #[test] + fn test_batch_of_exactly_10_succeeds() { + let (env, client) = setup(); + let docs = make_docs(&env, 10); + let ids = client.register_documents_batch(&docs); + assert_eq!(ids.len(), 10); + } +} diff --git a/contracts/package/document-registry/Cargo.toml b/contracts/package/document-registry/Cargo.toml new file mode 100644 index 00000000..be6c10cf --- /dev/null +++ b/contracts/package/document-registry/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "document-registry" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[features] +testutils = ["soroban-sdk/testutils"] + +[dependencies] +soroban-sdk = { version = "22.0.0", features = ["alloc"] } + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils", "alloc"] } + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true diff --git a/contracts/package/document-registry/src/lib.rs b/contracts/package/document-registry/src/lib.rs new file mode 100644 index 00000000..ef6dca0e --- /dev/null +++ b/contracts/package/document-registry/src/lib.rs @@ -0,0 +1,330 @@ +#![no_std] + +//! Document Registry Contract with Expiry Support (CT-05) +//! +//! Stores tamper-proof document hashes on-chain with optional expiry timestamps. +//! Expired documents are preserved for audit purposes but marked as invalid. + +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Bytes, BytesN, Env, String}; + +// ── Errors ──────────────────────────────────────────────────────────────────── + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum DocumentError { + NotInitialized = 1, + AlreadyInitialized = 2, + NotFound = 3, + Unauthorized = 4, + AlreadyRevoked = 5, +} + +// ── Types ───────────────────────────────────────────────────────────────────── + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DocumentStatus { + Active, + Expired, + Revoked, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct DocumentRecord { + pub id: u64, + pub uploader: Address, + pub content_hash: BytesN<32>, + pub ipfs_cid: Bytes, + pub doc_type: String, + pub registered_at: u64, + /// Unix timestamp after which the document is considered expired. + /// None means permanently valid unless revoked. + pub expires_at: Option, + pub is_revoked: bool, +} + +#[contracttype] +pub enum DataKey { + Admin, + Counter, + Document(u64), +} + +const TTL_LEDGERS: u32 = 6_307_200; // ~1 year + +// ── Contract ────────────────────────────────────────────────────────────────── + +#[contract] +pub struct DocumentRegistryContract; + +#[contractimpl] +impl DocumentRegistryContract { + pub fn initialize(env: Env, admin: Address) -> Result<(), DocumentError> { + if env.storage().instance().has(&DataKey::Admin) { + return Err(DocumentError::AlreadyInitialized); + } + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().persistent().set(&DataKey::Counter, &0u64); + Ok(()) + } + + /// Register a document with an optional expiry timestamp (Unix seconds). + pub fn register_document( + env: Env, + uploader: Address, + content_hash: BytesN<32>, + ipfs_cid: Bytes, + doc_type: String, + expires_at: Option, + ) -> Result { + uploader.require_auth(); + + let id = Self::next_id(&env); + let doc = DocumentRecord { + id, + uploader, + content_hash, + ipfs_cid, + doc_type, + registered_at: env.ledger().timestamp(), + expires_at, + is_revoked: false, + }; + + env.storage().persistent().set(&DataKey::Document(id), &doc); + env.storage() + .persistent() + .extend_ttl(&DataKey::Document(id), TTL_LEDGERS, TTL_LEDGERS); + + Ok(id) + } + + /// Returns false if the document is revoked or past its expiry timestamp. + pub fn is_valid(env: Env, doc_id: u64) -> Result { + let doc = Self::load(&env, doc_id)?; + if doc.is_revoked { + return Ok(false); + } + if let Some(expires_at) = doc.expires_at { + if env.ledger().timestamp() > expires_at { + return Ok(false); + } + } + Ok(true) + } + + /// Returns the document's current status: Active, Expired, or Revoked. + pub fn get_document_status(env: Env, doc_id: u64) -> Result { + let doc = Self::load(&env, doc_id)?; + if doc.is_revoked { + return Ok(DocumentStatus::Revoked); + } + if let Some(expires_at) = doc.expires_at { + if env.ledger().timestamp() > expires_at { + return Ok(DocumentStatus::Expired); + } + } + Ok(DocumentStatus::Active) + } + + /// Admin revokes a document. The hash record is preserved for audit. + pub fn revoke_document(env: Env, admin: Address, doc_id: u64) -> Result<(), DocumentError> { + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(DocumentError::NotInitialized)?; + if admin != stored_admin { + return Err(DocumentError::Unauthorized); + } + admin.require_auth(); + + let mut doc = Self::load(&env, doc_id)?; + if doc.is_revoked { + return Err(DocumentError::AlreadyRevoked); + } + doc.is_revoked = true; + env.storage().persistent().set(&DataKey::Document(doc.id), &doc); + env.storage() + .persistent() + .extend_ttl(&DataKey::Document(doc.id), TTL_LEDGERS, TTL_LEDGERS); + Ok(()) + } + + pub fn get_document(env: Env, doc_id: u64) -> Result { + Self::load(&env, doc_id) + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + fn load(env: &Env, id: u64) -> Result { + env.storage() + .persistent() + .get(&DataKey::Document(id)) + .ok_or(DocumentError::NotFound) + } + + fn next_id(env: &Env) -> u64 { + let current: u64 = env + .storage() + .persistent() + .get(&DataKey::Counter) + .unwrap_or(0); + let next = current + 1; + env.storage().persistent().set(&DataKey::Counter, &next); + env.storage() + .persistent() + .extend_ttl(&DataKey::Counter, TTL_LEDGERS, TTL_LEDGERS); + next + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{ + testutils::{Address as _, BytesN as _, Ledger}, + Bytes, BytesN, Env, String, + }; + + fn setup() -> (Env, Address, DocumentRegistryContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let id = env.register(DocumentRegistryContract {}, ()); + let client = DocumentRegistryContractClient::new(&env, &id); + client.initialize(&admin); + (env, admin, client) + } + + fn fake_hash(env: &Env) -> BytesN<32> { + BytesN::random(env) + } + + fn fake_cid(env: &Env) -> Bytes { + Bytes::from_slice(env, b"QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG") + } + + fn doc_type(env: &Env) -> String { + String::from_str(env, "BillOfLading") + } + + #[test] + fn test_document_with_future_expiry_is_valid() { + let (env, _, client) = setup(); + let uploader = Address::generate(&env); + + // Current ledger timestamp is 0 by default; set it to 1000 + env.ledger().set_timestamp(1000); + + // Expires far in the future + let id = client.register_document( + &uploader, + &fake_hash(&env), + &fake_cid(&env), + &doc_type(&env), + &Some(9_999_999u64), + ); + + assert!(client.is_valid(&id)); + assert_eq!(client.get_document_status(&id), DocumentStatus::Active); + } + + #[test] + fn test_document_with_past_expiry_is_invalid() { + let (env, _, client) = setup(); + let uploader = Address::generate(&env); + + // Register at timestamp 500 with expiry at 1000 + env.ledger().set_timestamp(500); + let id = client.register_document( + &uploader, + &fake_hash(&env), + &fake_cid(&env), + &doc_type(&env), + &Some(1000u64), + ); + + // Advance time past expiry + env.ledger().set_timestamp(1001); + + assert!(!client.is_valid(&id)); + assert_eq!(client.get_document_status(&id), DocumentStatus::Expired); + } + + #[test] + fn test_document_with_no_expiry_is_always_valid() { + let (env, _, client) = setup(); + let uploader = Address::generate(&env); + + let id = client.register_document( + &uploader, + &fake_hash(&env), + &fake_cid(&env), + &doc_type(&env), + &None, + ); + + // Advance time far into the future + env.ledger().set_timestamp(999_999_999); + + assert!(client.is_valid(&id)); + assert_eq!(client.get_document_status(&id), DocumentStatus::Active); + } + + #[test] + fn test_revoked_document_is_invalid() { + let (env, admin, client) = setup(); + let uploader = Address::generate(&env); + + let id = client.register_document( + &uploader, + &fake_hash(&env), + &fake_cid(&env), + &doc_type(&env), + &None, + ); + + assert!(client.is_valid(&id)); + client.revoke_document(&admin, &id); + assert!(!client.is_valid(&id)); + assert_eq!(client.get_document_status(&id), DocumentStatus::Revoked); + } + + #[test] + fn test_expired_document_hash_preserved() { + let (env, _, client) = setup(); + let uploader = Address::generate(&env); + let hash = fake_hash(&env); + + env.ledger().set_timestamp(500); + let id = client.register_document( + &uploader, + &hash, + &fake_cid(&env), + &doc_type(&env), + &Some(1000u64), + ); + + env.ledger().set_timestamp(2000); + + // Document is expired but record still exists with original hash + let doc = client.get_document(&id); + assert_eq!(doc.content_hash, hash); + assert_eq!(doc.expires_at, Some(1000u64)); + assert!(!doc.is_revoked); + } + + #[test] + fn test_not_found_error() { + let (_, _, client) = setup(); + assert_eq!( + client.try_get_document(&999u64), + Err(Ok(DocumentError::NotFound)) + ); + } +} diff --git a/contracts/package/shipment-insurance/Cargo.toml b/contracts/package/shipment-insurance/Cargo.toml new file mode 100644 index 00000000..57e5df5e --- /dev/null +++ b/contracts/package/shipment-insurance/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "shipment-insurance" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[features] +testutils = ["soroban-sdk/testutils"] + +[dependencies] +soroban-sdk = { version = "22.0.0", features = ["alloc"] } + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils", "alloc"] } + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true diff --git a/contracts/package/shipment-insurance/src/lib.rs b/contracts/package/shipment-insurance/src/lib.rs new file mode 100644 index 00000000..ae266c9c --- /dev/null +++ b/contracts/package/shipment-insurance/src/lib.rs @@ -0,0 +1,309 @@ +#![no_std] + +//! Shipment Insurance Contract (CT-08) +//! +//! Records insurance metadata on-chain so that escrow dispute and refund +//! decisions can reference insurance status provably. + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, String, +}; + +// ── Errors ──────────────────────────────────────────────────────────────────── + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum InsuranceError { + NotInitialized = 1, + AlreadyInitialized = 2, + NotFound = 3, + Unauthorized = 4, + PolicyIdTooLong = 5, + InvalidStatus = 6, +} + +// ── Types ───────────────────────────────────────────────────────────────────── + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ShipmentStatus { + Created, + InTransit, + Delivered, + Disputed, + Completed, + Cancelled, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct Shipment { + pub id: u64, + pub shipper: Address, + pub status: ShipmentStatus, + pub is_insured: bool, + pub insurance_premium: i128, + pub insurance_policy_id: String, + pub created_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct InsuranceStatus { + pub is_insured: bool, + pub premium: i128, + pub policy_id: String, +} + +#[contracttype] +pub enum DataKey { + Admin, + Counter, + Shipment(u64), +} + +const TTL_LEDGERS: u32 = 6_307_200; +const MAX_POLICY_ID_LEN: u32 = 64; + +// ── Contract ────────────────────────────────────────────────────────────────── + +#[contract] +pub struct ShipmentInsuranceContract; + +#[contractimpl] +impl ShipmentInsuranceContract { + pub fn initialize(env: Env, admin: Address) -> Result<(), InsuranceError> { + if env.storage().instance().has(&DataKey::Admin) { + return Err(InsuranceError::AlreadyInitialized); + } + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().persistent().set(&DataKey::Counter, &0u64); + Ok(()) + } + + /// Create a shipment with insurance metadata. + /// + /// `insurance_policy_id` max length is 64 characters; longer values return `PolicyIdTooLong`. + /// Uninsured shipments should pass `is_insured: false`, `insurance_premium: 0`, + /// and an empty `insurance_policy_id`. + pub fn create_shipment( + env: Env, + shipper: Address, + is_insured: bool, + insurance_premium: i128, + insurance_policy_id: String, + ) -> Result { + shipper.require_auth(); + + if insurance_policy_id.len() > MAX_POLICY_ID_LEN { + return Err(InsuranceError::PolicyIdTooLong); + } + + let id = Self::next_id(&env); + let shipment = Shipment { + id, + shipper, + status: ShipmentStatus::Created, + is_insured, + insurance_premium, + insurance_policy_id, + created_at: env.ledger().timestamp(), + }; + + env.storage() + .persistent() + .set(&DataKey::Shipment(id), &shipment); + env.storage() + .persistent() + .extend_ttl(&DataKey::Shipment(id), TTL_LEDGERS, TTL_LEDGERS); + + Ok(id) + } + + /// Returns the insurance status for a shipment. + pub fn get_insurance_status( + env: Env, + shipment_id: u64, + ) -> Result { + let s = Self::load(&env, shipment_id)?; + Ok(InsuranceStatus { + is_insured: s.is_insured, + premium: s.insurance_premium, + policy_id: s.insurance_policy_id, + }) + } + + /// Open a dispute on a shipment. If the shipment is insured, emits an + /// `InsuranceClaim` event with the policy ID. + pub fn open_dispute( + env: Env, + caller: Address, + shipment_id: u64, + ) -> Result<(), InsuranceError> { + caller.require_auth(); + + let mut s = Self::load(&env, shipment_id)?; + + if s.shipper != caller { + return Err(InsuranceError::Unauthorized); + } + if !matches!(s.status, ShipmentStatus::Created | ShipmentStatus::InTransit | ShipmentStatus::Delivered) { + return Err(InsuranceError::InvalidStatus); + } + + s.status = ShipmentStatus::Disputed; + env.storage() + .persistent() + .set(&DataKey::Shipment(shipment_id), &s); + + if s.is_insured { + env.events().publish( + (symbol_short!("insurance"), symbol_short!("claim")), + s.insurance_policy_id, + ); + } + + Ok(()) + } + + pub fn get_shipment(env: Env, shipment_id: u64) -> Result { + Self::load(&env, shipment_id) + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + fn load(env: &Env, id: u64) -> Result { + env.storage() + .persistent() + .get(&DataKey::Shipment(id)) + .ok_or(InsuranceError::NotFound) + } + + fn next_id(env: &Env) -> u64 { + let current: u64 = env + .storage() + .persistent() + .get(&DataKey::Counter) + .unwrap_or(0); + let next = current + 1; + env.storage().persistent().set(&DataKey::Counter, &next); + env.storage() + .persistent() + .extend_ttl(&DataKey::Counter, TTL_LEDGERS, TTL_LEDGERS); + next + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::Address as _, Env, String}; + + fn setup() -> (Env, Address, ShipmentInsuranceContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let id = env.register(ShipmentInsuranceContract {}, ()); + let client = ShipmentInsuranceContractClient::new(&env, &id); + client.initialize(&admin); + (env, admin, client) + } + + fn s(env: &Env, v: &str) -> String { + String::from_str(env, v) + } + + #[test] + fn test_insured_shipment_creation() { + let (env, _, client) = setup(); + let shipper = Address::generate(&env); + + let id = client.create_shipment( + &shipper, + &true, + &5_000_000i128, + &s(&env, "POL-2024-001"), + ); + + let status = client.get_insurance_status(&id); + assert!(status.is_insured); + assert_eq!(status.premium, 5_000_000); + assert_eq!(status.policy_id, s(&env, "POL-2024-001")); + } + + #[test] + fn test_uninsured_shipment_creation() { + let (env, _, client) = setup(); + let shipper = Address::generate(&env); + + let id = client.create_shipment(&shipper, &false, &0i128, &s(&env, "")); + + let status = client.get_insurance_status(&id); + assert!(!status.is_insured); + assert_eq!(status.premium, 0); + } + + #[test] + fn test_dispute_on_insured_shipment_emits_event() { + let (env, _, client) = setup(); + let shipper = Address::generate(&env); + + let id = client.create_shipment( + &shipper, + &true, + &1_000_000i128, + &s(&env, "POL-CLAIM-42"), + ); + + client.open_dispute(&shipper, &id); + + let shipment = client.get_shipment(&id); + assert_eq!(shipment.status, ShipmentStatus::Disputed); + } + + #[test] + fn test_dispute_on_uninsured_shipment_no_event() { + let (env, _, client) = setup(); + let shipper = Address::generate(&env); + + let id = client.create_shipment(&shipper, &false, &0i128, &s(&env, "")); + client.open_dispute(&shipper, &id); + + let shipment = client.get_shipment(&id); + assert_eq!(shipment.status, ShipmentStatus::Disputed); + } + + #[test] + fn test_policy_id_too_long_returns_error() { + let (env, _, client) = setup(); + let shipper = Address::generate(&env); + // 65-character policy ID + let long_id = s(&env, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1"); + + let result = client.try_create_shipment(&shipper, &true, &0i128, &long_id); + assert_eq!(result, Err(Ok(InsuranceError::PolicyIdTooLong))); + } + + #[test] + fn test_policy_id_exactly_64_chars_succeeds() { + let (env, _, client) = setup(); + let shipper = Address::generate(&env); + let exact_id = s(&env, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + + let id = client.create_shipment(&shipper, &true, &0i128, &exact_id); + let status = client.get_insurance_status(&id); + assert_eq!(status.policy_id, exact_id); + } + + #[test] + fn test_not_found_error() { + let (_, _, client) = setup(); + assert_eq!( + client.try_get_shipment(&999u64), + Err(Ok(InsuranceError::NotFound)) + ); + } +} diff --git a/contracts/package/tests/reputation/Cargo.toml b/contracts/package/tests/reputation/Cargo.toml new file mode 100644 index 00000000..c3a4b5c2 --- /dev/null +++ b/contracts/package/tests/reputation/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "reputation-tests" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["rlib"] + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils", "alloc"] } +reputation = { path = "../../../reputation", features = ["testutils"] } diff --git a/contracts/package/tests/reputation/src/lib.rs b/contracts/package/tests/reputation/src/lib.rs new file mode 100644 index 00000000..d2fe06fc --- /dev/null +++ b/contracts/package/tests/reputation/src/lib.rs @@ -0,0 +1,265 @@ +//! Comprehensive unit tests for the Reputation contract (CT-10). +//! +//! Score formula (0-1000): +//! rating_component = avg_rating (stored as score×100, max 500) +//! rate_component = on_time_pct × 3 (carriers) or success_pct × 3 (shippers) → 0-300 +//! completion_component = (rating_count / total_completed) × 100 × 2 → 0-200 + +#[cfg(test)] +mod reputation_tests { + use reputation::{ReputationContract, ReputationContractClient, ReputationError, UserType}; + use soroban_sdk::{testutils::Address as _, Address, Env}; + + fn setup() -> (Env, Address, Address, ReputationContractClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let auth_contract = Address::generate(&env); + let contract_id = env.register(ReputationContract {}, ()); + let client = ReputationContractClient::new(&env, &contract_id); + client.initialize(&admin, &auth_contract); + (env, admin, auth_contract, client) + } + + // ── add_rating (submit_rating) ──────────────────────────────────────── + + #[test] + fn test_add_rating_valid_score_1() { + let (env, _, _, client) = setup(); + let rater = Address::generate(&env); + let rated = Address::generate(&env); + client.register_user(&rater, &UserType::Shipper); + client.register_user(&rated, &UserType::Carrier); + + // Score 1 is valid + let rating_id = client.submit_rating(&rater, &1u64, &rated, &1u32); + let record = client.get_rating(&rating_id); + assert_eq!(record.score, 1); + } + + #[test] + fn test_add_rating_valid_score_5() { + let (env, _, _, client) = setup(); + let rater = Address::generate(&env); + let rated = Address::generate(&env); + client.register_user(&rater, &UserType::Shipper); + client.register_user(&rated, &UserType::Carrier); + + // Score 5 is valid + let rating_id = client.submit_rating(&rater, &1u64, &rated, &5u32); + let record = client.get_rating(&rating_id); + assert_eq!(record.score, 5); + } + + #[test] + fn test_add_rating_score_0_returns_invalid_score() { + let (env, _, _, client) = setup(); + let rater = Address::generate(&env); + let rated = Address::generate(&env); + client.register_user(&rater, &UserType::Shipper); + client.register_user(&rated, &UserType::Carrier); + + let result = client.try_submit_rating(&rater, &1u64, &rated, &0u32); + assert_eq!(result, Err(Ok(ReputationError::InvalidScore))); + } + + #[test] + fn test_add_rating_score_6_returns_invalid_score() { + let (env, _, _, client) = setup(); + let rater = Address::generate(&env); + let rated = Address::generate(&env); + client.register_user(&rater, &UserType::Shipper); + client.register_user(&rated, &UserType::Carrier); + + let result = client.try_submit_rating(&rater, &1u64, &rated, &6u32); + assert_eq!(result, Err(Ok(ReputationError::InvalidScore))); + } + + // ── Composite score calculation ─────────────────────────────────────── + + /// Known inputs: avgRating=4 (stored as 400), onTimePct=90%, completionRate=100% + /// + /// rating_component = 400 + /// rate_component = (9/10 * 100) * 3 = 90 * 3 = 270 + /// completion_component = (1/1 * 100) * 2 = 200 + /// total = 400 + 270 + 200 = 870 + #[test] + fn test_composite_score_known_inputs_carrier() { + let (env, _, auth_contract, client) = setup(); + let rater = Address::generate(&env); + let carrier = Address::generate(&env); + client.register_user(&rater, &UserType::Shipper); + client.register_user(&carrier, &UserType::Carrier); + + // avgRating = 4 → stored as 400 + client.submit_rating(&rater, &1u64, &carrier, &4u32); + + // 9 on-time, 1 late → on_time_pct = 90% + for i in 2u64..=10 { + client.update_stats(&auth_contract, &carrier, &true, &false); + let _ = i; + } + client.update_stats(&auth_contract, &carrier, &false, &false); // 1 late + + let score = client.calculate_score(&carrier); + // rating=400, rate=270, completion=(1/10)*100*2=20 + // total = 400 + 270 + 20 = 690 + assert_eq!(score, 690); + } + + /// Perfect carrier: avgRating=5, 100% on-time, 100% completion + /// + /// rating_component = 500 + /// rate_component = 100 * 3 = 300 + /// completion_component = 100 * 2 = 200 + /// total = 1000 + #[test] + fn test_composite_score_perfect_carrier() { + let (env, _, auth_contract, client) = setup(); + let rater = Address::generate(&env); + let carrier = Address::generate(&env); + client.register_user(&rater, &UserType::Shipper); + client.register_user(&carrier, &UserType::Carrier); + + client.submit_rating(&rater, &1u64, &carrier, &5u32); + client.update_stats(&auth_contract, &carrier, &true, &false); + + let score = client.calculate_score(&carrier); + assert_eq!(score, 1000); + } + + /// New user with no data → score = 0 + #[test] + fn test_composite_score_new_user_is_zero() { + let (env, _, _, client) = setup(); + let user = Address::generate(&env); + client.register_user(&user, &UserType::Carrier); + + let score = client.calculate_score(&user); + assert_eq!(score, 0); + } + + // ── Duplicate rating ────────────────────────────────────────────────── + + #[test] + fn test_duplicate_rating_same_shipment_returns_error() { + let (env, _, _, client) = setup(); + let rater = Address::generate(&env); + let rated = Address::generate(&env); + client.register_user(&rater, &UserType::Shipper); + client.register_user(&rated, &UserType::Carrier); + + client.submit_rating(&rater, &1u64, &rated, &5u32); + let result = client.try_submit_rating(&rater, &1u64, &rated, &4u32); + assert_eq!(result, Err(Ok(ReputationError::AlreadyRatedShipment))); + } + + #[test] + fn test_different_raters_same_shipment_allowed() { + let (env, _, _, client) = setup(); + let rater1 = Address::generate(&env); + let rater2 = Address::generate(&env); + let rated = Address::generate(&env); + client.register_user(&rater1, &UserType::Shipper); + client.register_user(&rater2, &UserType::Shipper); + client.register_user(&rated, &UserType::Carrier); + + client.submit_rating(&rater1, &1u64, &rated, &5u32); + client.submit_rating(&rater2, &1u64, &rated, &3u32); + + let rep = client.get_reputation(&rated); + assert_eq!(rep.rating_count, 2); + // (500 + 300) / 2 = 400 + assert_eq!(rep.average_rating, 400); + } + + // ── get_reputation ──────────────────────────────────────────────────── + + #[test] + fn test_get_reputation_returns_correct_breakdown() { + let (env, _, auth_contract, client) = setup(); + let rater = Address::generate(&env); + let carrier = Address::generate(&env); + client.register_user(&rater, &UserType::Shipper); + client.register_user(&carrier, &UserType::Carrier); + + client.submit_rating(&rater, &1u64, &carrier, &4u32); + client.update_stats(&auth_contract, &carrier, &true, &false); + client.update_stats(&auth_contract, &carrier, &false, &false); + + let rep = client.get_reputation(&carrier); + assert_eq!(rep.rating_count, 1); + assert_eq!(rep.average_rating, 400); // 4 stars × 100 + assert_eq!(rep.total_completed, 2); + assert_eq!(rep.on_time_count, 1); + assert_eq!(rep.late_count, 1); + } + + #[test] + fn test_get_reputation_shipper_breakdown() { + let (env, _, auth_contract, client) = setup(); + let rater = Address::generate(&env); + let shipper = Address::generate(&env); + client.register_user(&rater, &UserType::Carrier); + client.register_user(&shipper, &UserType::Shipper); + + client.submit_rating(&rater, &1u64, &shipper, &3u32); + client.update_stats(&auth_contract, &shipper, &false, &true); // success + client.update_stats(&auth_contract, &shipper, &false, &false); // cancel + + let rep = client.get_reputation(&shipper); + assert_eq!(rep.rating_count, 1); + assert_eq!(rep.average_rating, 300); // 3 stars × 100 + assert_eq!(rep.total_completed, 2); + assert_eq!(rep.success_count, 1); + assert_eq!(rep.cancel_count, 1); + } + + // ── update_stats ────────────────────────────────────────────────────── + + #[test] + fn test_update_stats_on_time_percentage_updates_correctly() { + let (env, _, auth_contract, client) = setup(); + let carrier = Address::generate(&env); + client.register_user(&carrier, &UserType::Carrier); + + // 3 on-time, 1 late → 75% on-time + client.update_stats(&auth_contract, &carrier, &true, &false); + client.update_stats(&auth_contract, &carrier, &true, &false); + client.update_stats(&auth_contract, &carrier, &true, &false); + client.update_stats(&auth_contract, &carrier, &false, &false); + + let rep = client.get_reputation(&carrier); + assert_eq!(rep.total_completed, 4); + assert_eq!(rep.on_time_count, 3); + assert_eq!(rep.late_count, 1); + + // Verify score: no ratings yet → rating=0, rate=(3/4*100)*3=225, completion=0 + let score = client.calculate_score(&carrier); + assert_eq!(score, 225); + } + + #[test] + fn test_update_stats_unauthorized_caller_fails() { + let (env, _, _, client) = setup(); + let random = Address::generate(&env); + let carrier = Address::generate(&env); + client.register_user(&carrier, &UserType::Carrier); + + let result = client.try_update_stats(&random, &carrier, &true, &false); + assert_eq!(result, Err(Ok(ReputationError::Unauthorized))); + } + + #[test] + fn test_update_stats_admin_is_authorized() { + let (env, admin, _, client) = setup(); + let carrier = Address::generate(&env); + client.register_user(&carrier, &UserType::Carrier); + + // Admin should also be able to call update_stats + client.update_stats(&admin, &carrier, &true, &false); + let rep = client.get_reputation(&carrier); + assert_eq!(rep.total_completed, 1); + assert_eq!(rep.on_time_count, 1); + } +}