diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock
index d697505f6..309c00681 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 62be99f9c..1e762782e 100644
--- a/contracts/Cargo.toml
+++ b/contracts/Cargo.toml
@@ -6,6 +6,7 @@ members = [
"contrib",
"multisig-wallet",
"multisig_transfer",
+ "opsce",
]
[workspace.dependencies]
diff --git a/contracts/opsce/Cargo.toml b/contracts/opsce/Cargo.toml
new file mode 100644
index 000000000..c28b16dde
--- /dev/null
+++ b/contracts/opsce/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "opsce"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+crate-type = ["lib", "cdylib"]
+doctest = false
+
+[dependencies]
+soroban-sdk = { workspace = true }
+
+[dev-dependencies]
+soroban-sdk = { workspace = true, features = ["testutils"] }
diff --git a/contracts/opsce/src/error.rs b/contracts/opsce/src/error.rs
new file mode 100644
index 000000000..733018380
--- /dev/null
+++ b/contracts/opsce/src/error.rs
@@ -0,0 +1,20 @@
+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,
+}
diff --git a/contracts/opsce/src/lib.rs b/contracts/opsce/src/lib.rs
new file mode 100644
index 000000000..a0c536aab
--- /dev/null
+++ b/contracts/opsce/src/lib.rs
@@ -0,0 +1,232 @@
+#![no_std]
+
+use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, String, Vec};
+
+pub mod error;
+pub mod maintenance_record;
+pub mod multisig_revoke;
+pub mod types;
+
+#[cfg(test)]
+mod tests;
+
+pub use crate::error::ContractError;
+pub use crate::types::{
+ DataKey, 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))
+ }
+
+ /// 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_record.rs b/contracts/opsce/src/maintenance_record.rs
new file mode 100644
index 000000000..c8131fc80
--- /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 000000000..757f2b1a1
--- /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 000000000..a8a1e8bd2
--- /dev/null
+++ b/contracts/opsce/src/tests.rs
@@ -0,0 +1,238 @@
+#![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)));
+}
diff --git a/contracts/opsce/src/types.rs b/contracts/opsce/src/types.rs
new file mode 100644
index 000000000..198d7c02a
--- /dev/null
+++ b/contracts/opsce/src/types.rs
@@ -0,0 +1,72 @@
+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,
+}
+
+/// 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),
+}