diff --git a/smart-contract/contracts/src/error.rs b/smart-contract/contracts/src/error.rs index 5dd5b1d5..5399948a 100644 --- a/smart-contract/contracts/src/error.rs +++ b/smart-contract/contracts/src/error.rs @@ -59,4 +59,20 @@ pub enum Error { InvalidThreshold = 66, TooManySigners = 67, DuplicateSigner = 68, + + // --- Sustainability (70-80) --- + /// No sustainability record exists for this product. + SustainabilityNotFound = 70, + /// Carbon footprint value is negative. + InvalidCarbonData = 71, + /// Water usage value is negative. + InvalidWaterData = 72, + /// Renewable energy percentage is out of range (must be 0–100). + InvalidRenewableEnergyData = 73, + /// Waste-recycled percentage is out of range (must be 0–100). + InvalidWasteData = 74, + /// Record has already been verified and cannot be updated. + SustainabilityAlreadyVerified = 75, + /// Operation requires a verified sustainability record. + SustainabilityClaimUnverified = 76, } diff --git a/smart-contract/contracts/src/lib.rs b/smart-contract/contracts/src/lib.rs index 716a4489..a6015ec1 100644 --- a/smart-contract/contracts/src/lib.rs +++ b/smart-contract/contracts/src/lib.rs @@ -40,6 +40,8 @@ mod stats; mod tracking; #[cfg(not(target_arch = "wasm32"))] mod upgrade; +#[cfg(not(target_arch = "wasm32"))] +mod sustainability; #[cfg(test)] mod load_tests; @@ -76,3 +78,5 @@ pub use stats::*; pub use tracking::*; #[cfg(not(target_arch = "wasm32"))] pub use upgrade::*; +#[cfg(not(target_arch = "wasm32"))] +pub use sustainability::*; diff --git a/smart-contract/contracts/src/storage.rs b/smart-contract/contracts/src/storage.rs index 4d4d2661..3a95f8df 100644 --- a/smart-contract/contracts/src/storage.rs +++ b/smart-contract/contracts/src/storage.rs @@ -1,7 +1,7 @@ use soroban_sdk::{Address, Env, String, Symbol, Vec}; use crate::storage_contract::StorageContract; -use crate::types::{Product, TrackingEvent}; +use crate::types::{Product, SustainabilityRecord, TrackingEvent}; pub fn get_auth_contract(env: &Env) -> Option
{ StorageContract::get_auth_contract(env) @@ -178,3 +178,17 @@ pub fn remove_from_search_index(env: &Env, keyword: String, product_id: &String) put_search_index(env, &keyword, &ids); } } + +// ─── Sustainability ────────────────────────────────────────────────────────── + +pub fn put_sustainability(env: &Env, product_id: &String, record: &SustainabilityRecord) { + StorageContract::put_sustainability(env, product_id, record) +} + +pub fn get_sustainability(env: &Env, product_id: &String) -> Option { + StorageContract::get_sustainability(env, product_id) +} + +pub fn has_sustainability(env: &Env, product_id: &String) -> bool { + StorageContract::has_sustainability(env, product_id) +} diff --git a/smart-contract/contracts/src/storage_contract.rs b/smart-contract/contracts/src/storage_contract.rs index 3b5cce89..de5040a7 100644 --- a/smart-contract/contracts/src/storage_contract.rs +++ b/smart-contract/contracts/src/storage_contract.rs @@ -1,6 +1,6 @@ use soroban_sdk::{Address, Env, String, Symbol, Vec}; -use crate::types::{DataKey, Product, TrackingEvent}; +use crate::types::{DataKey, Product, SustainabilityRecord, TrackingEvent}; pub struct StorageContract; @@ -279,6 +279,30 @@ impl StorageContract { .instance() .set(&Self::active_products_key(), &count); } + + // ─── Sustainability ────────────────────────────────────────────────────── + + pub fn sustainability_key(product_id: &String) -> DataKey { + DataKey::Sustainability(product_id.clone()) + } + + pub fn put_sustainability(env: &Env, product_id: &String, record: &SustainabilityRecord) { + env.storage() + .instance() + .set(&Self::sustainability_key(product_id), record); + } + + pub fn get_sustainability(env: &Env, product_id: &String) -> Option { + env.storage() + .instance() + .get(&Self::sustainability_key(product_id)) + } + + pub fn has_sustainability(env: &Env, product_id: &String) -> bool { + env.storage() + .instance() + .has(&Self::sustainability_key(product_id)) + } } #[cfg(test)] diff --git a/smart-contract/contracts/src/sustainability.rs b/smart-contract/contracts/src/sustainability.rs new file mode 100644 index 00000000..4b99d4ae --- /dev/null +++ b/smart-contract/contracts/src/sustainability.rs @@ -0,0 +1,391 @@ +use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, String, Symbol}; + +use crate::error::Error; +use crate::storage; +use crate::types::{SustainabilityRecord, SustainabilityStatus}; + +fn require_admin(env: &Env, caller: &Address) -> Result<(), Error> { + let admin = storage::get_admin(env).ok_or(Error::NotInitialized)?; + if &admin != caller { + return Err(Error::Unauthorized); + } + caller.require_auth(); + Ok(()) +} + +fn validate_record( + carbon_footprint_g: i128, + water_usage_ml: i128, + renewable_energy_pct: u32, + waste_recycled_pct: u32, +) -> Result<(), Error> { + if carbon_footprint_g < 0 { + return Err(Error::InvalidCarbonData); + } + if water_usage_ml < 0 { + return Err(Error::InvalidWaterData); + } + if renewable_energy_pct > 100 { + return Err(Error::InvalidRenewableEnergyData); + } + if waste_recycled_pct > 100 { + return Err(Error::InvalidWasteData); + } + Ok(()) +} + +/// Contract for recording and verifying supply-chain sustainability claims. +/// +/// Flow: +/// 1. A product owner calls `record_sustainability` to submit environmental data. +/// 2. An admin calls `verify_sustainability` (anchoring a certificate hash) or +/// `reject_sustainability` to update the record status. +/// 3. Anyone can call `get_sustainability` to read the current record. +#[contract] +pub struct SustainabilityContract; + +#[contractimpl] +impl SustainabilityContract { + /// Submit a sustainability record for a product. + /// + /// Fails if a `Verified` record already exists for this product. + /// Overwrites a `Pending` or `Rejected` record. + pub fn record_sustainability( + env: Env, + caller: Address, + product_id: String, + carbon_footprint_g: i128, + water_usage_ml: i128, + renewable_energy_pct: u32, + waste_recycled_pct: u32, + labor_compliance_hash: BytesN<32>, + certificate_hash: Option>, + ) -> Result<(), Error> { + caller.require_auth(); + + validate_record( + carbon_footprint_g, + water_usage_ml, + renewable_energy_pct, + waste_recycled_pct, + )?; + + // Prevent overwriting an already-verified record. + if let Some(existing) = storage::get_sustainability(&env, &product_id) { + if existing.status == SustainabilityStatus::Verified { + return Err(Error::SustainabilityAlreadyVerified); + } + } + + let record = SustainabilityRecord { + carbon_footprint_g, + water_usage_ml, + renewable_energy_pct, + waste_recycled_pct, + labor_compliance_hash, + certificate_hash, + status: SustainabilityStatus::Pending, + recorded_at: env.ledger().timestamp(), + recorded_by: caller.clone(), + verified_by: None, + verified_at: 0, + }; + + storage::put_sustainability(&env, &product_id, &record); + + env.events().publish( + (Symbol::new(&env, "sustainability"), Symbol::new(&env, "recorded")), + (product_id, caller), + ); + + Ok(()) + } + + /// Mark a pending sustainability record as verified (admin only). + /// + /// `certificate_hash` is the hash of the third-party verification document + /// that is stored off-chain and anchored here for auditability. + pub fn verify_sustainability( + env: Env, + admin: Address, + product_id: String, + certificate_hash: BytesN<32>, + ) -> Result<(), Error> { + require_admin(&env, &admin)?; + + let mut record = storage::get_sustainability(&env, &product_id) + .ok_or(Error::SustainabilityNotFound)?; + + if record.status == SustainabilityStatus::Verified { + return Err(Error::SustainabilityAlreadyVerified); + } + + record.status = SustainabilityStatus::Verified; + record.certificate_hash = Some(certificate_hash); + record.verified_by = Some(admin.clone()); + record.verified_at = env.ledger().timestamp(); + + storage::put_sustainability(&env, &product_id, &record); + + env.events().publish( + (Symbol::new(&env, "sustainability"), Symbol::new(&env, "verified")), + (product_id, admin), + ); + + Ok(()) + } + + /// Mark a pending sustainability record as rejected (admin only). + pub fn reject_sustainability( + env: Env, + admin: Address, + product_id: String, + ) -> Result<(), Error> { + require_admin(&env, &admin)?; + + let mut record = storage::get_sustainability(&env, &product_id) + .ok_or(Error::SustainabilityNotFound)?; + + if record.status == SustainabilityStatus::Verified { + return Err(Error::SustainabilityAlreadyVerified); + } + + record.status = SustainabilityStatus::Rejected; + record.verified_by = Some(admin.clone()); + record.verified_at = env.ledger().timestamp(); + + storage::put_sustainability(&env, &product_id, &record); + + env.events().publish( + (Symbol::new(&env, "sustainability"), Symbol::new(&env, "rejected")), + (product_id, admin), + ); + + Ok(()) + } + + /// Retrieve the sustainability record for a product. + pub fn get_sustainability( + env: Env, + product_id: String, + ) -> Result { + storage::get_sustainability(&env, &product_id) + .ok_or(Error::SustainabilityNotFound) + } +} + +#[cfg(test)] +mod sustainability_tests { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::{Address, BytesN, Env}; + + fn setup() -> (Env, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, SustainabilityContract); + env.as_contract(&contract_id, || { + storage::set_admin(&env, &admin); + }); + let owner = Address::generate(&env); + (env, admin, owner) + } + + fn zero_hash(env: &Env) -> BytesN<32> { + BytesN::from_array(env, &[0u8; 32]) + } + + fn one_hash(env: &Env) -> BytesN<32> { + BytesN::from_array(env, &[1u8; 32]) + } + + #[test] + fn test_record_and_get() { + let (env, _admin, owner) = setup(); + let contract_id = env.register_contract(None, SustainabilityContract); + let client = SustainabilityContractClient::new(&env, &contract_id); + + env.as_contract(&contract_id, || { + storage::set_admin(&env, &_admin); + }); + + let product_id = soroban_sdk::String::from_str(&env, "P001"); + + client + .record_sustainability( + &owner, + &product_id, + &5000_i128, + &10000_i128, + &80_u32, + &60_u32, + &zero_hash(&env), + &None, + ) + .unwrap(); + + let record = client.get_sustainability(&product_id).unwrap(); + assert_eq!(record.status, SustainabilityStatus::Pending); + assert_eq!(record.carbon_footprint_g, 5000); + assert_eq!(record.renewable_energy_pct, 80); + } + + #[test] + fn test_verify_sustainability() { + let (env, admin, owner) = setup(); + let contract_id = env.register_contract(None, SustainabilityContract); + let client = SustainabilityContractClient::new(&env, &contract_id); + + env.as_contract(&contract_id, || { + storage::set_admin(&env, &admin); + }); + + let product_id = soroban_sdk::String::from_str(&env, "P002"); + + client + .record_sustainability( + &owner, + &product_id, + &1000_i128, + &500_i128, + &100_u32, + &100_u32, + &zero_hash(&env), + &None, + ) + .unwrap(); + + client + .verify_sustainability(&admin, &product_id, &one_hash(&env)) + .unwrap(); + + let record = client.get_sustainability(&product_id).unwrap(); + assert_eq!(record.status, SustainabilityStatus::Verified); + assert_eq!(record.certificate_hash, Some(one_hash(&env))); + } + + #[test] + fn test_reject_sustainability() { + let (env, admin, owner) = setup(); + let contract_id = env.register_contract(None, SustainabilityContract); + let client = SustainabilityContractClient::new(&env, &contract_id); + + env.as_contract(&contract_id, || { + storage::set_admin(&env, &admin); + }); + + let product_id = soroban_sdk::String::from_str(&env, "P003"); + + client + .record_sustainability( + &owner, + &product_id, + &0_i128, + &0_i128, + &0_u32, + &0_u32, + &zero_hash(&env), + &None, + ) + .unwrap(); + + client.reject_sustainability(&admin, &product_id).unwrap(); + + let record = client.get_sustainability(&product_id).unwrap(); + assert_eq!(record.status, SustainabilityStatus::Rejected); + } + + #[test] + fn test_invalid_carbon_data() { + let (env, _admin, owner) = setup(); + let contract_id = env.register_contract(None, SustainabilityContract); + let client = SustainabilityContractClient::new(&env, &contract_id); + + env.as_contract(&contract_id, || { + storage::set_admin(&env, &_admin); + }); + + let product_id = soroban_sdk::String::from_str(&env, "P004"); + + let result = client.try_record_sustainability( + &owner, + &product_id, + &(-1_i128), + &0_i128, + &0_u32, + &0_u32, + &zero_hash(&env), + &None, + ); + assert!(result.is_err()); + } + + #[test] + fn test_invalid_percentage() { + let (env, _admin, owner) = setup(); + let contract_id = env.register_contract(None, SustainabilityContract); + let client = SustainabilityContractClient::new(&env, &contract_id); + + env.as_contract(&contract_id, || { + storage::set_admin(&env, &_admin); + }); + + let product_id = soroban_sdk::String::from_str(&env, "P005"); + + let result = client.try_record_sustainability( + &owner, + &product_id, + &0_i128, + &0_i128, + &101_u32, + &0_u32, + &zero_hash(&env), + &None, + ); + assert!(result.is_err()); + } + + #[test] + fn test_cannot_overwrite_verified() { + let (env, admin, owner) = setup(); + let contract_id = env.register_contract(None, SustainabilityContract); + let client = SustainabilityContractClient::new(&env, &contract_id); + + env.as_contract(&contract_id, || { + storage::set_admin(&env, &admin); + }); + + let product_id = soroban_sdk::String::from_str(&env, "P006"); + + client + .record_sustainability( + &owner, + &product_id, + &0_i128, + &0_i128, + &0_u32, + &0_u32, + &zero_hash(&env), + &None, + ) + .unwrap(); + + client + .verify_sustainability(&admin, &product_id, &one_hash(&env)) + .unwrap(); + + // Attempting to re-record a verified entry must fail. + let result = client.try_record_sustainability( + &owner, + &product_id, + &0_i128, + &0_i128, + &0_u32, + &0_u32, + &zero_hash(&env), + &None, + ); + assert!(result.is_err()); + } +} diff --git a/smart-contract/contracts/src/types.rs b/smart-contract/contracts/src/types.rs index 27e643ff..8b48dbe4 100644 --- a/smart-contract/contracts/src/types.rs +++ b/smart-contract/contracts/src/types.rs @@ -1,5 +1,53 @@ use soroban_sdk::{contracttype, Address, BytesN, Map, String, Symbol, Val, Vec}; +// ─── Sustainability Types ────────────────────────────────────────────────────── + +/// Verification status of a sustainability record. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SustainabilityStatus { + /// Submitted and awaiting third-party review. + Pending, + /// Approved and anchored on-chain with a certificate hash. + Verified, + /// Rejected due to invalid or unverifiable claims. + Rejected, +} + +/// On-chain sustainability record for a supply-chain product. +/// +/// Numeric environmental values use integer milli-units to avoid floating point: +/// - `carbon_footprint_g`: CO₂ in grams +/// - `water_usage_ml`: water consumed in millilitres +/// - `renewable_energy_pct`: 0–100 integer percentage +/// - `waste_recycled_pct`: 0–100 integer percentage +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SustainabilityRecord { + /// CO₂-equivalent emissions in grams (non-negative). + pub carbon_footprint_g: i128, + /// Water consumption in millilitres (non-negative). + pub water_usage_ml: i128, + /// Percentage of energy from renewable sources (0–100). + pub renewable_energy_pct: u32, + /// Percentage of waste that is recycled (0–100). + pub waste_recycled_pct: u32, + /// Hash of the off-chain labor-compliance documentation. + pub labor_compliance_hash: BytesN<32>, + /// Optional third-party certification document hash. + pub certificate_hash: Option>, + /// Current verification status. + pub status: SustainabilityStatus, + /// Ledger timestamp when this record was submitted. + pub recorded_at: u64, + /// Address that submitted this record. + pub recorded_by: Address, + /// Address that verified/rejected this record, if any. + pub verified_by: Option
, + /// Ledger timestamp of verification/rejection (0 if pending). + pub verified_at: u64, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct DeactInfo { @@ -104,6 +152,7 @@ pub enum DataKey { MultiSigConfig, // Multi-signature configuration Proposal(u64), // Proposal by ID NextProposalId, // Next proposal ID counter + Sustainability(String), // SustainabilityRecord keyed by product_id } #[contracttype]