diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index d697505f..309c0068 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -872,6 +872,13 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "opsce" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "p256" version = "0.13.2" diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 1a790a3d..1e762782 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -6,7 +6,7 @@ members = [ "contrib", "multisig-wallet", "multisig_transfer", - "./opsce", + "opsce", ] [workspace.dependencies] diff --git a/contracts/opsce/Cargo.toml b/contracts/opsce/Cargo.toml index 475c8f32..c28b16dd 100644 --- a/contracts/opsce/Cargo.toml +++ b/contracts/opsce/Cargo.toml @@ -3,12 +3,11 @@ name = "opsce" version = "0.1.0" edition = "2021" -t[lib] +[lib] crate-type = ["lib", "cdylib"] doctest = false [dependencies] -assetsup = { path = "../assetsup" } soroban-sdk = { workspace = true } [dev-dependencies] diff --git a/contracts/opsce/src/error.rs b/contracts/opsce/src/error.rs new file mode 100644 index 00000000..96373ef8 --- /dev/null +++ b/contracts/opsce/src/error.rs @@ -0,0 +1,24 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum ContractError { + WalletNotFound = 1, + TransactionNotFound = 2, + NotAnOwner = 3, + AlreadyApproved = 4, + ApprovalNotFound = 5, + AlreadyExecuted = 6, + InsufficientApprovals = 7, + InvalidThreshold = 8, + InsufficientOwners = 9, + InvalidAssetId = 10, + InvalidCost = 11, + DuplicateRecord = 12, + RecordNotFound = 13, + AdminNotSet = 14, + NotAdmin = 15, + AlertNotFound = 16, + AdminAlreadySet = 17, +} diff --git a/contracts/opsce/src/lib.rs b/contracts/opsce/src/lib.rs index bbf28826..2ea4133e 100644 --- a/contracts/opsce/src/lib.rs +++ b/contracts/opsce/src/lib.rs @@ -1,13 +1,258 @@ #![no_std] -mod insurance_claim; -mod batch_transfer; +use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, String, Vec}; -pub use insurance_claim::*; -pub use batch_transfer::*; +pub mod error; +pub mod maintenance_alerts; +pub mod maintenance_record; +pub mod multisig_revoke; +pub mod types; #[cfg(test)] -mod insurance_claim_test; +mod tests; -#[cfg(test)] -mod batch_transfer_test; +pub use crate::error::ContractError; +pub use crate::types::{ + AlertSeverity, AlertType, DataKey, MaintenanceAlert, MaintenanceRecord, MaintenanceRecordType, + MaintenanceStatus, Transaction, Wallet, +}; + +#[contract] +pub struct OpsceMultisig; + +#[contractimpl] +impl OpsceMultisig { + /// Create a new multisig wallet and return its `wallet_id`. + pub fn create_wallet( + env: Env, + admin: Address, + owners: Vec
, + threshold: u32, + ) -> Result { + admin.require_auth(); + + if owners.len() < 2 { + return Err(ContractError::InsufficientOwners); + } + if threshold == 0 || threshold > owners.len() { + return Err(ContractError::InvalidThreshold); + } + + let wallet_id: u64 = env + .storage() + .instance() + .get(&DataKey::NextWalletId) + .unwrap_or(1); + env.storage() + .instance() + .set(&DataKey::NextWalletId, &(wallet_id + 1)); + + let wallet = Wallet { + id: wallet_id, + owners, + threshold, + }; + env.storage() + .persistent() + .set(&DataKey::Wallet(wallet_id), &wallet); + env.storage() + .persistent() + .set(&DataKey::NextTxId(wallet_id), &1u64); + + Ok(wallet_id) + } + + /// Submit a new transaction proposal for a wallet. Returns its `tx_id`. + pub fn submit_transaction( + env: Env, + initiator: Address, + wallet_id: u64, + ) -> Result { + initiator.require_auth(); + + let wallet: Wallet = env + .storage() + .persistent() + .get(&DataKey::Wallet(wallet_id)) + .ok_or(ContractError::WalletNotFound)?; + + if !wallet.owners.contains(&initiator) { + return Err(ContractError::NotAnOwner); + } + + let tx_id: u64 = env + .storage() + .persistent() + .get(&DataKey::NextTxId(wallet_id)) + .unwrap_or(1); + env.storage() + .persistent() + .set(&DataKey::NextTxId(wallet_id), &(tx_id + 1)); + + let tx = Transaction { + id: tx_id, + wallet_id, + initiator, + approvers: Vec::new(&env), + approvals: 0, + executed: false, + }; + env.storage() + .persistent() + .set(&DataKey::Transaction(wallet_id, tx_id), &tx); + + Ok(tx_id) + } + + /// Approve a pending transaction. + pub fn approve_transaction( + env: Env, + caller: Address, + wallet_id: u64, + tx_id: u64, + ) -> Result<(), ContractError> { + caller.require_auth(); + + let wallet: Wallet = env + .storage() + .persistent() + .get(&DataKey::Wallet(wallet_id)) + .ok_or(ContractError::WalletNotFound)?; + + if !wallet.owners.contains(&caller) { + return Err(ContractError::NotAnOwner); + } + + let mut tx: Transaction = env + .storage() + .persistent() + .get(&DataKey::Transaction(wallet_id, tx_id)) + .ok_or(ContractError::TransactionNotFound)?; + + if tx.executed { + return Err(ContractError::AlreadyExecuted); + } + if tx.approvers.contains(&caller) { + return Err(ContractError::AlreadyApproved); + } + + tx.approvers.push_back(caller); + tx.approvals += 1; + + env.storage() + .persistent() + .set(&DataKey::Transaction(wallet_id, tx_id), &tx); + + Ok(()) + } + + /// Execute the transaction once the approval threshold has been reached. + pub fn execute_transaction( + env: Env, + wallet_id: u64, + tx_id: u64, + ) -> Result<(), ContractError> { + let wallet: Wallet = env + .storage() + .persistent() + .get(&DataKey::Wallet(wallet_id)) + .ok_or(ContractError::WalletNotFound)?; + + let mut tx: Transaction = env + .storage() + .persistent() + .get(&DataKey::Transaction(wallet_id, tx_id)) + .ok_or(ContractError::TransactionNotFound)?; + + if tx.executed { + return Err(ContractError::AlreadyExecuted); + } + if tx.approvals < wallet.threshold { + return Err(ContractError::InsufficientApprovals); + } + + tx.executed = true; + env.storage() + .persistent() + .set(&DataKey::Transaction(wallet_id, tx_id), &tx); + + Ok(()) + } + + /// Revoke a previously submitted approval (see [`multisig_revoke::revoke_approval`]). + pub fn revoke_approval( + env: Env, + caller: Address, + wallet_id: u64, + tx_id: u64, + ) -> Result<(), ContractError> { + multisig_revoke::revoke_approval(&env, caller, wallet_id, tx_id) + } + + /// Create a maintenance record (see [`maintenance_record::create_maintenance_record`]). + pub fn create_maintenance_record( + env: Env, + asset_id: String, + record_type: MaintenanceRecordType, + provider: Address, + scheduled_date: u64, + cost: i128, + notes: String, + ) -> Result, ContractError> { + maintenance_record::create_maintenance_record( + &env, + asset_id, + record_type, + provider, + scheduled_date, + cost, + notes, + ) + } + + /// Get all maintenance records associated with the given `asset_id`. + pub fn get_maintenance_records(env: Env, asset_id: String) -> Vec { + maintenance_record::get_maintenance_records(&env, asset_id) + } + + /// Get a single maintenance record by its `record_id`. + pub fn get_maintenance_record( + env: Env, + record_id: BytesN<32>, + ) -> Option { + env.storage() + .persistent() + .get(&DataKey::MaintenanceRecord(record_id)) + } + + /// One-time admin initialization (used by alert dismissal). + pub fn set_admin(env: Env, admin: Address) -> Result<(), ContractError> { + maintenance_alerts::set_admin(&env, admin) + } + + /// Evaluate scheduled maintenance and generate / return alerts due within 7 days. + pub fn check_maintenance_alerts(env: Env, asset_id: String) -> Vec { + maintenance_alerts::check_maintenance_alerts(&env, asset_id) + } + + /// Return all unresolved alerts for the given asset. + pub fn get_active_alerts(env: Env, asset_id: String) -> Vec { + maintenance_alerts::get_active_alerts(&env, asset_id) + } + + /// Mark an alert resolved (admin only). + pub fn dismiss_alert( + env: Env, + caller: Address, + alert_id: BytesN<32>, + ) -> Result<(), ContractError> { + maintenance_alerts::dismiss_alert(&env, caller, alert_id) + } + + /// Read-only getter for tests / clients. + pub fn get_transaction(env: Env, wallet_id: u64, tx_id: u64) -> Option { + env.storage() + .persistent() + .get(&DataKey::Transaction(wallet_id, tx_id)) + } +} diff --git a/contracts/opsce/src/maintenance_alerts.rs b/contracts/opsce/src/maintenance_alerts.rs new file mode 100644 index 00000000..06f65bed --- /dev/null +++ b/contracts/opsce/src/maintenance_alerts.rs @@ -0,0 +1,196 @@ +use soroban_sdk::{Address, Bytes, BytesN, Env, String, Symbol, Vec}; + +use crate::error::ContractError; +use crate::maintenance_record::get_maintenance_records; +use crate::types::{ + AlertSeverity, AlertType, DataKey, MaintenanceAlert, MaintenanceStatus, +}; + +const ONE_DAY_SECS: u64 = 86_400; +const THREE_DAYS_SECS: u64 = 3 * ONE_DAY_SECS; +const SEVEN_DAYS_SECS: u64 = 7 * ONE_DAY_SECS; + +/// Set the contract admin. May only be called once. +pub fn set_admin(env: &Env, admin: Address) -> Result<(), ContractError> { + admin.require_auth(); + if env.storage().instance().has(&DataKey::Admin) { + return Err(ContractError::AdminAlreadySet); + } + env.storage().instance().set(&DataKey::Admin, &admin); + Ok(()) +} + +/// Evaluate all `Scheduled` records for `asset_id` against the current ledger +/// timestamp and return alerts due within the next 7 days (or already overdue). +/// +/// Severity bands (relative to `scheduled_date`): +/// - `Critical` — already overdue (`scheduled_date < now`) +/// - `High` — due within 1 day +/// - `Medium` — due within 3 days +/// - `Low` — due within 7 days +/// +/// Newly generated alerts are persisted, indexed per asset, and an +/// `alert_generated` event is emitted for each new alert. Alerts that already +/// exist (same `record_id` + `severity`) are not duplicated, but are returned +/// in the result if still unresolved. +pub fn check_maintenance_alerts(env: &Env, asset_id: String) -> Vec { + let records = get_maintenance_records(env, asset_id.clone()); + let now = env.ledger().timestamp(); + let mut out: Vec = Vec::new(env); + + for record in records.iter() { + // Only schedule-status records can produce alerts. + if record.status != MaintenanceStatus::Scheduled { + continue; + } + + let severity = match compute_severity(record.scheduled_date, now) { + Some(s) => s, + None => continue, + }; + let alert_type = if record.scheduled_date < now { + AlertType::Overdue + } else { + AlertType::ServiceDue + }; + + let alert_id = derive_alert_id(env, &record.record_id, severity); + + // Deduplicate: if an alert with the same id already exists, keep it. + if let Some(existing) = env + .storage() + .persistent() + .get::<_, MaintenanceAlert>(&DataKey::Alert(alert_id.clone())) + { + if !existing.resolved { + out.push_back(existing); + } + continue; + } + + let alert = MaintenanceAlert { + alert_id: alert_id.clone(), + asset_id: asset_id.clone(), + record_id: record.record_id.clone(), + alert_type, + severity, + due_date: record.scheduled_date, + created_at: now, + resolved: false, + }; + + env.storage() + .persistent() + .set(&DataKey::Alert(alert_id.clone()), &alert); + + let mut index: Vec> = env + .storage() + .persistent() + .get(&DataKey::AlertIndex(asset_id.clone())) + .unwrap_or_else(|| Vec::new(env)); + index.push_back(alert_id.clone()); + env.storage() + .persistent() + .set(&DataKey::AlertIndex(asset_id.clone()), &index); + + // Emit `alert_generated` event. + let topic = Symbol::new(env, "alert_generated"); + env.events().publish( + (topic, alert_id), + (severity, record.record_id.clone(), record.scheduled_date), + ); + + out.push_back(alert); + } + + out +} + +/// Return all unresolved alerts for the given `asset_id`. +pub fn get_active_alerts(env: &Env, asset_id: String) -> Vec { + let index: Vec> = env + .storage() + .persistent() + .get(&DataKey::AlertIndex(asset_id)) + .unwrap_or_else(|| Vec::new(env)); + + let mut out = Vec::new(env); + for id in index.iter() { + if let Some(alert) = env + .storage() + .persistent() + .get::<_, MaintenanceAlert>(&DataKey::Alert(id)) + { + if !alert.resolved { + out.push_back(alert); + } + } + } + out +} + +/// Mark an alert as resolved. Admin-only. +pub fn dismiss_alert( + env: &Env, + caller: Address, + alert_id: BytesN<32>, +) -> Result<(), ContractError> { + caller.require_auth(); + + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(ContractError::AdminNotSet)?; + if admin != caller { + return Err(ContractError::NotAdmin); + } + + let mut alert: MaintenanceAlert = env + .storage() + .persistent() + .get(&DataKey::Alert(alert_id.clone())) + .ok_or(ContractError::AlertNotFound)?; + + alert.resolved = true; + env.storage() + .persistent() + .set(&DataKey::Alert(alert_id), &alert); + + Ok(()) +} + +/// Compute alert severity for a scheduled timestamp `due` relative to `now`. +/// Returns `None` if the record is more than 7 days away. +fn compute_severity(due: u64, now: u64) -> Option { + if due < now { + return Some(AlertSeverity::Critical); + } + let delta = due - now; + if delta <= ONE_DAY_SECS { + Some(AlertSeverity::High) + } else if delta <= THREE_DAYS_SECS { + Some(AlertSeverity::Medium) + } else if delta <= SEVEN_DAYS_SECS { + Some(AlertSeverity::Low) + } else { + None + } +} + +fn severity_byte(s: AlertSeverity) -> u8 { + match s { + AlertSeverity::Low => 1, + AlertSeverity::Medium => 2, + AlertSeverity::High => 3, + AlertSeverity::Critical => 4, + } +} + +/// `alert_id = sha256(record_id_bytes || [severity_byte])`. +fn derive_alert_id(env: &Env, record_id: &BytesN<32>, severity: AlertSeverity) -> BytesN<32> { + let arr = record_id.to_array(); + let mut data = Bytes::from_slice(env, &arr); + data.append(&Bytes::from_slice(env, &[severity_byte(severity)])); + env.crypto().sha256(&data).to_bytes() +} diff --git a/contracts/opsce/src/maintenance_record.rs b/contracts/opsce/src/maintenance_record.rs new file mode 100644 index 00000000..c8131fc8 --- /dev/null +++ b/contracts/opsce/src/maintenance_record.rs @@ -0,0 +1,134 @@ +use soroban_sdk::{Address, Bytes, BytesN, Env, String, Symbol, Vec}; + +use crate::error::ContractError; +use crate::types::{ + DataKey, MaintenanceRecord, MaintenanceRecordType, MaintenanceStatus, +}; + +/// Maximum length (bytes) of an `asset_id` we will hash on the stack. +/// Soroban contracts run in `no_std`, so we use a fixed-size scratch buffer. +const MAX_ASSET_ID_LEN: usize = 256; + +/// Create a new maintenance record on-chain. +/// +/// Acceptance criteria: +/// - Validates that `asset_id` is non-empty (`InvalidAssetId`). +/// - Validates that `cost` is non-negative (`InvalidCost`). +/// - Generates a unique `record_id` from `sha256(asset_id || timestamp)`. +/// - Stores under [`DataKey::MaintenanceRecord`]. +/// - Indexes the record under the asset's id list ([`DataKey::MaintenanceIndex`]). +/// - Emits a `maintenance_scheduled` event. +/// - Returns `DuplicateRecord` if the same `record_id` already exists +/// (asset_id + timestamp collision within a single ledger). +#[allow(clippy::too_many_arguments)] +pub fn create_maintenance_record( + env: &Env, + asset_id: String, + record_type: MaintenanceRecordType, + provider: Address, + scheduled_date: u64, + cost: i128, + notes: String, +) -> Result, ContractError> { + provider.require_auth(); + + // 1. Validate inputs. + if asset_id.len() == 0 { + return Err(ContractError::InvalidAssetId); + } + if cost < 0 { + return Err(ContractError::InvalidCost); + } + + let timestamp = env.ledger().timestamp(); + + // 2. Derive a unique `record_id = sha256(asset_id || timestamp_be)`. + let record_id = derive_record_id(env, &asset_id, timestamp)?; + + // 3. Duplicate prevention. + if env + .storage() + .persistent() + .has(&DataKey::MaintenanceRecord(record_id.clone())) + { + return Err(ContractError::DuplicateRecord); + } + + // 4. Build and persist the record. + let record = MaintenanceRecord { + record_id: record_id.clone(), + asset_id: asset_id.clone(), + record_type, + provider: provider.clone(), + scheduled_date, + cost, + notes, + status: MaintenanceStatus::Scheduled, + created_at: timestamp, + }; + + env.storage() + .persistent() + .set(&DataKey::MaintenanceRecord(record_id.clone()), &record); + + // 5. Append to per-asset index. + let mut index: Vec> = env + .storage() + .persistent() + .get(&DataKey::MaintenanceIndex(asset_id.clone())) + .unwrap_or_else(|| Vec::new(env)); + index.push_back(record_id.clone()); + env.storage() + .persistent() + .set(&DataKey::MaintenanceIndex(asset_id), &index); + + // 6. Emit `maintenance_scheduled` event. + let topic = Symbol::new(env, "maintenance_scheduled"); + env.events() + .publish((topic, record_id.clone()), (provider, timestamp)); + + Ok(record_id) +} + +/// Return all maintenance records associated with `asset_id`. +pub fn get_maintenance_records(env: &Env, asset_id: String) -> Vec { + let index: Vec> = env + .storage() + .persistent() + .get(&DataKey::MaintenanceIndex(asset_id)) + .unwrap_or_else(|| Vec::new(env)); + + let mut out: Vec = Vec::new(env); + for id in index.iter() { + if let Some(record) = env + .storage() + .persistent() + .get::<_, MaintenanceRecord>(&DataKey::MaintenanceRecord(id)) + { + out.push_back(record); + } + } + out +} + +/// Build `record_id = sha256(asset_id_bytes || timestamp_be_bytes)`. +fn derive_record_id( + env: &Env, + asset_id: &String, + timestamp: u64, +) -> Result, ContractError> { + let len = asset_id.len() as usize; + if len == 0 || len > MAX_ASSET_ID_LEN { + return Err(ContractError::InvalidAssetId); + } + + // Copy String bytes onto a fixed-size stack buffer (no_std friendly). + let mut scratch = [0u8; MAX_ASSET_ID_LEN]; + asset_id.copy_into_slice(&mut scratch[..len]); + + let mut data = Bytes::from_slice(env, &scratch[..len]); + let ts_bytes = timestamp.to_be_bytes(); + data.append(&Bytes::from_slice(env, &ts_bytes)); + + Ok(env.crypto().sha256(&data).to_bytes()) +} diff --git a/contracts/opsce/src/multisig_revoke.rs b/contracts/opsce/src/multisig_revoke.rs new file mode 100644 index 00000000..757f2b1a --- /dev/null +++ b/contracts/opsce/src/multisig_revoke.rs @@ -0,0 +1,73 @@ +use soroban_sdk::{Address, Env, Symbol, Vec}; + +use crate::error::ContractError; +use crate::types::{DataKey, Transaction, Wallet}; + +/// Revoke a previously submitted approval for a pending transaction. +/// +/// Acceptance criteria: +/// - Caller must be an existing owner of the wallet (`NotAnOwner`). +/// - If caller has not approved the transaction, returns `ApprovalNotFound`. +/// - If the transaction is already executed, returns `AlreadyExecuted`. +/// - Decrements the approval count and removes the caller from the approvers list. +/// - Emits an `approval_revoked` event with `tx_id` and revoker `Address`. +pub fn revoke_approval( + env: &Env, + caller: Address, + wallet_id: u64, + tx_id: u64, +) -> Result<(), ContractError> { + caller.require_auth(); + + // Load wallet and check that the caller is an owner. + let wallet: Wallet = env + .storage() + .persistent() + .get(&DataKey::Wallet(wallet_id)) + .ok_or(ContractError::WalletNotFound)?; + + if !wallet.owners.contains(&caller) { + return Err(ContractError::NotAnOwner); + } + + // Load the transaction. + let mut tx: Transaction = env + .storage() + .persistent() + .get(&DataKey::Transaction(wallet_id, tx_id)) + .ok_or(ContractError::TransactionNotFound)?; + + // Cannot revoke once executed. + if tx.executed { + return Err(ContractError::AlreadyExecuted); + } + + // Find the caller in the approvers list; if missing, there is nothing to revoke. + let position = find_approver(&tx.approvers, &caller) + .ok_or(ContractError::ApprovalNotFound)?; + + // Remove the caller from approvers and decrement the approval count. + tx.approvers.remove(position); + tx.approvals = tx.approvals.saturating_sub(1); + + env.storage() + .persistent() + .set(&DataKey::Transaction(wallet_id, tx_id), &tx); + + // Emit `approval_revoked` event with tx_id and revoker. + let topic = Symbol::new(env, "approval_revoked"); + env.events().publish((topic, tx_id), caller); + + Ok(()) +} + +fn find_approver(approvers: &Vec
, target: &Address) -> Option { + let mut idx: u32 = 0; + for a in approvers.iter() { + if &a == target { + return Some(idx); + } + idx += 1; + } + None +} diff --git a/contracts/opsce/src/tests.rs b/contracts/opsce/src/tests.rs new file mode 100644 index 00000000..036cf5e1 --- /dev/null +++ b/contracts/opsce/src/tests.rs @@ -0,0 +1,381 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::testutils::{Address as _, Ledger as _}; +use soroban_sdk::{Address, Env, String, Vec}; + +fn setup() -> (Env, OpsceMultisigClient<'static>, Address, Address, Address, u64) { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(OpsceMultisig, ()); + let client = OpsceMultisigClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let owner1 = Address::generate(&env); + let owner2 = Address::generate(&env); + let owners = Vec::from_array(&env, [owner1.clone(), owner2.clone()]); + + let wallet_id = client.create_wallet(&admin, &owners, &2u32); + + (env, client, admin, owner1, owner2, wallet_id) +} + +#[test] +fn test_revoke_approval_valid() { + let (_env, client, _admin, owner1, owner2, wallet_id) = setup(); + + let tx_id = client.submit_transaction(&owner1, &wallet_id); + client.approve_transaction(&owner1, &wallet_id, &tx_id); + client.approve_transaction(&owner2, &wallet_id, &tx_id); + + let tx = client.get_transaction(&wallet_id, &tx_id).unwrap(); + assert_eq!(tx.approvals, 2); + assert!(tx.approvers.contains(&owner1)); + + // owner1 revokes their approval before execution. + client.revoke_approval(&owner1, &wallet_id, &tx_id); + + let tx = client.get_transaction(&wallet_id, &tx_id).unwrap(); + assert_eq!(tx.approvals, 1); + assert!(!tx.approvers.contains(&owner1)); + assert!(tx.approvers.contains(&owner2)); + assert!(!tx.executed); +} + +#[test] +fn test_revoke_approval_double_revocation_fails() { + let (_env, client, _admin, owner1, _owner2, wallet_id) = setup(); + + let tx_id = client.submit_transaction(&owner1, &wallet_id); + client.approve_transaction(&owner1, &wallet_id, &tx_id); + + // First revocation succeeds. + client.revoke_approval(&owner1, &wallet_id, &tx_id); + + // Second revocation must fail with ApprovalNotFound. + let result = client.try_revoke_approval(&owner1, &wallet_id, &tx_id); + assert_eq!(result, Err(Ok(ContractError::ApprovalNotFound))); +} + +#[test] +fn test_revoke_approval_after_execution_fails() { + let (_env, client, _admin, owner1, owner2, wallet_id) = setup(); + + let tx_id = client.submit_transaction(&owner1, &wallet_id); + client.approve_transaction(&owner1, &wallet_id, &tx_id); + client.approve_transaction(&owner2, &wallet_id, &tx_id); + + // Execute the transaction. + client.execute_transaction(&wallet_id, &tx_id); + let tx = client.get_transaction(&wallet_id, &tx_id).unwrap(); + assert!(tx.executed); + + // Revocation after execution must fail with AlreadyExecuted. + let result = client.try_revoke_approval(&owner1, &wallet_id, &tx_id); + assert_eq!(result, Err(Ok(ContractError::AlreadyExecuted))); +} + +#[test] +fn test_revoke_approval_non_owner_fails() { + let (env, client, _admin, owner1, _owner2, wallet_id) = setup(); + + let tx_id = client.submit_transaction(&owner1, &wallet_id); + client.approve_transaction(&owner1, &wallet_id, &tx_id); + + let intruder = Address::generate(&env); + let result = client.try_revoke_approval(&intruder, &wallet_id, &tx_id); + assert_eq!(result, Err(Ok(ContractError::NotAnOwner))); +} + +// --------------------------------------------------------------------------- +// Maintenance record tests +// --------------------------------------------------------------------------- + +fn setup_maintenance() -> (Env, OpsceMultisigClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(OpsceMultisig, ()); + let client = OpsceMultisigClient::new(&env, &contract_id); + + let provider = Address::generate(&env); + (env, client, provider) +} + +#[test] +fn test_create_maintenance_record() { + let (env, client, provider) = setup_maintenance(); + + let asset_id = String::from_str(&env, "asset-123"); + let notes = String::from_str(&env, "Quarterly inspection"); + + let record_id = client.create_maintenance_record( + &asset_id, + &MaintenanceRecordType::Preventive, + &provider, + &1_700_000_000u64, + &500i128, + ¬es, + ); + + let record = client.get_maintenance_record(&record_id).unwrap(); + assert_eq!(record.asset_id, asset_id); + assert_eq!(record.provider, provider); + assert_eq!(record.cost, 500); + assert_eq!(record.scheduled_date, 1_700_000_000u64); + assert_eq!(record.status, MaintenanceStatus::Scheduled); + assert_eq!(record.record_type, MaintenanceRecordType::Preventive); +} + +#[test] +fn test_get_maintenance_records_for_asset() { + let (env, client, provider) = setup_maintenance(); + + let asset_a = String::from_str(&env, "asset-A"); + let asset_b = String::from_str(&env, "asset-B"); + let notes = String::from_str(&env, "note"); + + // Two records for asset_a in different ledgers (so timestamps differ). + client.create_maintenance_record( + &asset_a, + &MaintenanceRecordType::Preventive, + &provider, + &1_700_000_000u64, + &100i128, + ¬es, + ); + env.ledger().set_timestamp(env.ledger().timestamp() + 60); + client.create_maintenance_record( + &asset_a, + &MaintenanceRecordType::Corrective, + &provider, + &1_700_000_100u64, + &200i128, + ¬es, + ); + // One record for asset_b. + env.ledger().set_timestamp(env.ledger().timestamp() + 60); + client.create_maintenance_record( + &asset_b, + &MaintenanceRecordType::Inspection, + &provider, + &1_700_000_200u64, + &50i128, + ¬es, + ); + + let a_records = client.get_maintenance_records(&asset_a); + assert_eq!(a_records.len(), 2); + assert_eq!(a_records.get(0).unwrap().cost, 100); + assert_eq!(a_records.get(1).unwrap().cost, 200); + + let b_records = client.get_maintenance_records(&asset_b); + assert_eq!(b_records.len(), 1); + assert_eq!(b_records.get(0).unwrap().cost, 50); + + // Unknown asset returns empty Vec. + let unknown = client.get_maintenance_records(&String::from_str(&env, "asset-Z")); + assert_eq!(unknown.len(), 0); +} + +#[test] +fn test_create_maintenance_record_duplicate_fails() { + let (env, client, provider) = setup_maintenance(); + + let asset_id = String::from_str(&env, "asset-dup"); + let notes = String::from_str(&env, "duplicate test"); + + client.create_maintenance_record( + &asset_id, + &MaintenanceRecordType::Preventive, + &provider, + &1_700_000_000u64, + &500i128, + ¬es, + ); + + // Same asset_id + same ledger timestamp => same record_id => duplicate. + let result = client.try_create_maintenance_record( + &asset_id, + &MaintenanceRecordType::Preventive, + &provider, + &1_700_000_000u64, + &500i128, + ¬es, + ); + assert_eq!(result, Err(Ok(ContractError::DuplicateRecord))); +} + +#[test] +fn test_create_maintenance_record_empty_asset_id_fails() { + let (env, client, provider) = setup_maintenance(); + + let result = client.try_create_maintenance_record( + &String::from_str(&env, ""), + &MaintenanceRecordType::Preventive, + &provider, + &1_700_000_000u64, + &100i128, + &String::from_str(&env, ""), + ); + assert_eq!(result, Err(Ok(ContractError::InvalidAssetId))); +} + +#[test] +fn test_create_maintenance_record_negative_cost_fails() { + let (env, client, provider) = setup_maintenance(); + + let result = client.try_create_maintenance_record( + &String::from_str(&env, "asset-neg"), + &MaintenanceRecordType::Preventive, + &provider, + &1_700_000_000u64, + &-1i128, + &String::from_str(&env, ""), + ); + assert_eq!(result, Err(Ok(ContractError::InvalidCost))); +} + +// --------------------------------------------------------------------------- +// Maintenance alert tests +// --------------------------------------------------------------------------- + +const BASE_TS: u64 = 1_700_000_000; +const ONE_DAY: u64 = 86_400; + +fn setup_alerts() -> (Env, OpsceMultisigClient<'static>, Address, Address, String) { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(BASE_TS); + + let contract_id = env.register(OpsceMultisig, ()); + let client = OpsceMultisigClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let provider = Address::generate(&env); + client.set_admin(&admin); + + let asset_id = String::from_str(&env, "asset-A"); + (env, client, admin, provider, asset_id) +} + +fn schedule_record( + env: &Env, + client: &OpsceMultisigClient<'_>, + asset_id: &String, + provider: &Address, + scheduled_date: u64, +) { + client.create_maintenance_record( + asset_id, + &MaintenanceRecordType::Preventive, + provider, + &scheduled_date, + &100i128, + &String::from_str(env, "note"), + ); +} + +#[test] +fn test_check_maintenance_alerts_no_alert_when_far_future() { + let (env, client, _admin, provider, asset_id) = setup_alerts(); + // Scheduled 10 days out -> no alert (> 7 days). + schedule_record(&env, &client, &asset_id, &provider, BASE_TS + 10 * ONE_DAY); + + let alerts = client.check_maintenance_alerts(&asset_id); + assert_eq!(alerts.len(), 0); + assert_eq!(client.get_active_alerts(&asset_id).len(), 0); +} + +#[test] +fn test_check_maintenance_alerts_low_severity_at_seven_days() { + let (env, client, _admin, provider, asset_id) = setup_alerts(); + // 6 days out -> Low (in (3, 7]). + schedule_record(&env, &client, &asset_id, &provider, BASE_TS + 6 * ONE_DAY); + + let alerts = client.check_maintenance_alerts(&asset_id); + assert_eq!(alerts.len(), 1); + assert_eq!(alerts.get(0).unwrap().severity, AlertSeverity::Low); + assert_eq!(alerts.get(0).unwrap().alert_type, AlertType::ServiceDue); +} + +#[test] +fn test_check_maintenance_alerts_medium_severity() { + let (env, client, _admin, provider, asset_id) = setup_alerts(); + // 2 days out -> Medium (in (1, 3]). + schedule_record(&env, &client, &asset_id, &provider, BASE_TS + 2 * ONE_DAY); + + let alerts = client.check_maintenance_alerts(&asset_id); + assert_eq!(alerts.len(), 1); + assert_eq!(alerts.get(0).unwrap().severity, AlertSeverity::Medium); +} + +#[test] +fn test_check_maintenance_alerts_high_severity() { + let (env, client, _admin, provider, asset_id) = setup_alerts(); + // 12 hours out -> High. + schedule_record(&env, &client, &asset_id, &provider, BASE_TS + ONE_DAY / 2); + + let alerts = client.check_maintenance_alerts(&asset_id); + assert_eq!(alerts.len(), 1); + assert_eq!(alerts.get(0).unwrap().severity, AlertSeverity::High); +} + +#[test] +fn test_check_maintenance_alerts_overdue_critical() { + let (env, client, _admin, provider, asset_id) = setup_alerts(); + // Schedule one in the future, then advance the ledger past it. + schedule_record(&env, &client, &asset_id, &provider, BASE_TS + ONE_DAY); + env.ledger().set_timestamp(BASE_TS + 3 * ONE_DAY); + + let alerts = client.check_maintenance_alerts(&asset_id); + assert_eq!(alerts.len(), 1); + let a = alerts.get(0).unwrap(); + assert_eq!(a.severity, AlertSeverity::Critical); + assert_eq!(a.alert_type, AlertType::Overdue); +} + +#[test] +fn test_check_maintenance_alerts_dedupes_same_severity() { + let (env, client, _admin, provider, asset_id) = setup_alerts(); + schedule_record(&env, &client, &asset_id, &provider, BASE_TS + 6 * ONE_DAY); + + let first = client.check_maintenance_alerts(&asset_id); + let second = client.check_maintenance_alerts(&asset_id); + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_eq!( + first.get(0).unwrap().alert_id, + second.get(0).unwrap().alert_id + ); + // Index should still hold a single alert id. + assert_eq!(client.get_active_alerts(&asset_id).len(), 1); +} + +#[test] +fn test_dismiss_alert_resolves_alert() { + let (env, client, admin, provider, asset_id) = setup_alerts(); + schedule_record(&env, &client, &asset_id, &provider, BASE_TS + ONE_DAY / 2); + + let alerts = client.check_maintenance_alerts(&asset_id); + let alert_id = alerts.get(0).unwrap().alert_id; + + client.dismiss_alert(&admin, &alert_id); + + let active = client.get_active_alerts(&asset_id); + assert_eq!(active.len(), 0); +} + +#[test] +fn test_dismiss_alert_non_admin_fails() { + let (env, client, _admin, provider, asset_id) = setup_alerts(); + schedule_record(&env, &client, &asset_id, &provider, BASE_TS + ONE_DAY / 2); + + let alerts = client.check_maintenance_alerts(&asset_id); + let alert_id = alerts.get(0).unwrap().alert_id; + + let intruder = Address::generate(&env); + let result = client.try_dismiss_alert(&intruder, &alert_id); + assert_eq!(result, Err(Ok(ContractError::NotAdmin))); +} diff --git a/contracts/opsce/src/types.rs b/contracts/opsce/src/types.rs new file mode 100644 index 00000000..ce22de9c --- /dev/null +++ b/contracts/opsce/src/types.rs @@ -0,0 +1,107 @@ +use soroban_sdk::{contracttype, Address, BytesN, String, Vec}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Wallet { + pub id: u64, + pub owners: Vec
, + pub threshold: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Transaction { + pub id: u64, + pub wallet_id: u64, + pub initiator: Address, + pub approvers: Vec
, + pub approvals: u32, + pub executed: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MaintenanceStatus { + Scheduled, + InProgress, + Completed, + Cancelled, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MaintenanceRecordType { + Preventive, + Corrective, + Emergency, + Inspection, + Upgrade, + Calibration, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaintenanceRecord { + pub record_id: BytesN<32>, + pub asset_id: String, + pub record_type: MaintenanceRecordType, + pub provider: Address, + pub scheduled_date: u64, + pub cost: i128, + pub notes: String, + pub status: MaintenanceStatus, + pub created_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AlertType { + ServiceDue, + Overdue, +} + +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AlertSeverity { + Low, + Medium, + High, + Critical, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaintenanceAlert { + pub alert_id: BytesN<32>, + pub asset_id: String, + pub record_id: BytesN<32>, + pub alert_type: AlertType, + pub severity: AlertSeverity, + pub due_date: u64, + pub created_at: u64, + pub resolved: bool, +} + +/// Storage keys for the opsce multisig contract. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DataKey { + /// Stores a `Wallet` keyed by its `wallet_id`. + Wallet(u64), + /// Stores a `Transaction` keyed by `(wallet_id, tx_id)`. + Transaction(u64, u64), + /// Auto-incrementing transaction id per wallet. + NextTxId(u64), + /// Auto-incrementing wallet id (instance scope). + NextWalletId, + /// Stores a `MaintenanceRecord` keyed by its content-derived id. + MaintenanceRecord(BytesN<32>), + /// Per-asset index of `record_id`s for fast retrieval. + MaintenanceIndex(String), + /// Contract administrator (instance scope). + Admin, + /// Stores a `MaintenanceAlert` keyed by its content-derived id. + Alert(BytesN<32>), + /// Per-asset index of `alert_id`s. + AlertIndex(String), +}