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),
+}