From f8a3f7cc2f7278eeda40d01bf0c50c2fa7d8e327 Mon Sep 17 00:00:00 2001 From: Ummi-001 Date: Mon, 19 Oct 2026 16:20:20 +0100 Subject: [PATCH] fix(contracts): guard maintenance logging, add admin + wasm allowlist controls asset-maintenance (#1477, #1478, #1479): - reject logging maintenance against a detokenized/reactivated asset - add an admin-gated emergency pause covering every mutating entrypoint - add amend/void flags so a mistaken maintenance record can be corrected without erasing history assetsup (#1496): - maintain an admin-managed WASM-hash allowlist and require the new hash to be approved before `upgrade` runs Also consolidate the assetsup Error enum (61 -> 49 variants) to fit the 50-case contract-spec limit, and repair pre-existing test-build breakage in both crates so `cargo test` compiles. Closes #1477 Closes #1478 Closes #1479 Closes #1496 --- contracts/Cargo.lock | 1 + contracts/ERRORS.md | 22 + contracts/asset-maintenance/src/error.rs | 12 + contracts/asset-maintenance/src/events.rs | 116 ++++- contracts/asset-maintenance/src/lib.rs | 477 +++++++++++++++--- contracts/asset-maintenance/src/proptests.rs | 2 + contracts/asset-maintenance/src/test.rs | 6 + .../asset-maintenance/src/tests_coverage.rs | 360 ++++++++++++- contracts/assetsup/README.md | 4 +- contracts/assetsup/src/error.rs | 138 +++-- contracts/assetsup/src/events.rs | 38 ++ contracts/assetsup/src/insurance.rs | 4 +- contracts/assetsup/src/lease.rs | 4 +- contracts/assetsup/src/lib.rs | 92 +++- contracts/assetsup/src/tests/asset.rs | 6 +- contracts/assetsup/src/tests/pause.rs | 8 + contracts/assetsup/src/tests/pause_ttl.rs | 2 +- contracts/assetsup/src/tests/ttl.rs | 7 +- contracts/assetsup/src/tests/upgrade.rs | 74 +++ 19 files changed, 1225 insertions(+), 148 deletions(-) diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index d890e47d..40afc66f 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -154,6 +154,7 @@ dependencies = [ name = "asset-maintenance" version = "0.1.0" dependencies = [ + "proptest", "soroban-sdk", ] diff --git a/contracts/ERRORS.md b/contracts/ERRORS.md index c12dbc76..193c6082 100644 --- a/contracts/ERRORS.md +++ b/contracts/ERRORS.md @@ -75,6 +75,27 @@ code 1 across different contracts. | 170–179 | Validation | | 180–199 | Leasing and insurance | +The enum is capped at **50 cases** by the contract spec, so narrow +single-subject variants were folded into generic ones. The following codes are +retired and must not be reused: + +| Retired code | Former variant | Now returns | +|---:|---|---| +| 103 | `BranchAlreadyExists` | `AlreadyExists` (101) | +| 104 | `BranchNotFound` | `NotFound` (5) | +| 105 | `SubscriptionAlreadyExists` | `AlreadyExists` (101) | +| 123 | `InvalidTokenDecimals` | `InvalidInput` (4) | +| 142 | `ProposalNotFound` | `NotFound` (5) | +| 170 | `InvalidAssetName` | `InvalidInput` (4) | +| 171 | `InvalidPurchaseValue` | `InvalidInput` (4) | +| 172 | `InvalidMetadataUri` | `InvalidInput` (4) | +| 180 | `LeaseNotFound` | `NotFound` (5) | +| 181 | `LeaseAlreadyExists` | `AlreadyExists` (101) | +| 194 | `PolicyAlreadyExists` | `AlreadyExists` (101) | +| 195 | `ClaimAlreadyExists` | `AlreadyExists` (101) | + +Code `101` was renamed from `AssetAlreadyExists` to `AlreadyExists`. + ## `contrib` (200–299) Typed `Error` enum lives in `contrib/src/error.rs` and is wired into the crate. @@ -114,6 +135,7 @@ Typed `Error` enum lives in `asset-maintenance/src/error.rs`. Failures use | Block | Concern | |---:|---| | 500–509 | Providers, schedules, warranties, alerts | +| 510–513 | Maintenance records (voiding/amending) and detokenized assets | ## For backend implementers diff --git a/contracts/asset-maintenance/src/error.rs b/contracts/asset-maintenance/src/error.rs index 93ca41af..9208b115 100644 --- a/contracts/asset-maintenance/src/error.rs +++ b/contracts/asset-maintenance/src/error.rs @@ -42,6 +42,14 @@ pub enum Error { AssetNotFound = 508, /// Invalid date / cost / rating arguments. InvalidArguments = 509, + /// A record that has already been voided cannot be voided again. + RecordAlreadyVoided = 510, + /// No maintenance record with that id exists for the asset. + RecordNotFound = 511, + /// A voided record can no longer be amended. + RecordVoided = 512, + /// The asset has been detokenized/burned and accepts no new maintenance. + AssetDetokenized = 513, } pub fn handle_error(env: &Env, error: Error) -> ! { @@ -67,6 +75,10 @@ mod tests { Error::AlertIndexOutOfBounds as u32, Error::AssetNotFound as u32, Error::InvalidArguments as u32, + Error::RecordAlreadyVoided as u32, + Error::RecordNotFound as u32, + Error::RecordVoided as u32, + Error::AssetDetokenized as u32, ] { assert!((500..600).contains(&code)); } diff --git a/contracts/asset-maintenance/src/events.rs b/contracts/asset-maintenance/src/events.rs index b586c1fb..ea296de8 100644 --- a/contracts/asset-maintenance/src/events.rs +++ b/contracts/asset-maintenance/src/events.rs @@ -5,7 +5,7 @@ //! `lower_snake_case`. `asset_id` is always a `#[topic]` so consumers can index //! per asset. -use soroban_sdk::{contractevent, Address, Env}; +use soroban_sdk::{contractevent, Address, Env, String}; use crate::{AlertSeverity, AlertType, MaintenanceAlert, MaintenanceRecord, ScheduledMaintenance}; @@ -141,6 +141,66 @@ pub struct AlertAcknowledged { pub timestamp: u64, } +/// The contract was paused; new maintenance records now reject. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractPaused { + #[topic] + pub admin: Address, + pub timestamp: u64, +} + +/// The contract was unpaused and resumed normal operation. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractUnpaused { + #[topic] + pub admin: Address, + pub timestamp: u64, +} + +/// A previously recorded maintenance entry was voided. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaintenanceRecordVoided { + #[topic] + pub asset_id: u64, + pub record_id: u64, + pub voided_by: Address, + pub timestamp: u64, +} + +/// A previously recorded maintenance entry was amended. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaintenanceRecordAmended { + #[topic] + pub asset_id: u64, + pub record_id: u64, + pub amended_by: Address, + pub timestamp: u64, +} + +/// An asset was marked detokenized/burned and accepts no new maintenance. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AssetDetokenized { + #[topic] + pub asset_id: u64, + pub admin: Address, + pub timestamp: u64, +} + +/// A detokenized asset was reactivated. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AssetReactivated { + #[topic] + pub asset_id: u64, + pub admin: Address, + pub timestamp: u64, +} + // --------------------------------------------------------------------------- // Emission helpers // --------------------------------------------------------------------------- @@ -263,3 +323,57 @@ pub fn alert_acknowledged(env: &Env, asset_id: u64, alert_index: u32, by: &Addre } .publish(env); } + +pub fn contract_paused(env: &Env, admin: &Address) { + ContractPaused { + admin: admin.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn contract_unpaused(env: &Env, admin: &Address) { + ContractUnpaused { + admin: admin.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn maintenance_record_voided(env: &Env, asset_id: u64, record_id: u64, by: &Address) { + MaintenanceRecordVoided { + asset_id, + record_id, + voided_by: by.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn maintenance_record_amended(env: &Env, asset_id: u64, record_id: u64, by: &Address) { + MaintenanceRecordAmended { + asset_id, + record_id, + amended_by: by.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn asset_detokenized(env: &Env, asset_id: u64, admin: &Address) { + AssetDetokenized { + asset_id, + admin: admin.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn asset_reactivated(env: &Env, asset_id: u64, admin: &Address) { + AssetReactivated { + asset_id, + admin: admin.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} diff --git a/contracts/asset-maintenance/src/lib.rs b/contracts/asset-maintenance/src/lib.rs index eefac4c4..f8378a7e 100644 --- a/contracts/asset-maintenance/src/lib.rs +++ b/contracts/asset-maintenance/src/lib.rs @@ -9,11 +9,19 @@ //! //! ## Invariants //! -//! - Maintenance history is append-only — no entrypoint removes or rewrites an -//! existing [`MaintenanceRecord`]. +//! - Maintenance history is append-only — no entrypoint removes an existing +//! [`MaintenanceRecord`] from the stored [`Vec`]. A record that was filed by +//! mistake is *voided* (its `voided` flag is set and it is excluded from the +//! [`AssetStats`] rollup) or *amended* (a [`MaintenanceAmendment`] sidecar +//! carries the corrected values) but the original entry is never erased, so +//! the audit trail remains reconstructable. //! - [`AssetStats`] is a derived rollup (total cost, downtime, service count, -//! health score) maintained alongside the history. +//! health score) recomputed from the live (non-voided, amendment-adjusted) +//! history. //! - Health scores are expressed on a 1–100 scale. +//! - The admin can pause the contract, which blocks new maintenance logging. +//! - The admin can mark an asset detokenized (burned); such an asset accepts +//! neither new records nor new schedules until it is reactivated. //! //! ## Error handling //! @@ -27,10 +35,10 @@ use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Ve pub mod error; pub mod events; -pub mod ttl; +mod proptests; mod test; mod tests_coverage; -mod proptests; +pub mod ttl; pub use crate::error::{handle_error, Error}; @@ -103,6 +111,41 @@ pub struct MaintenanceRecord { pub documents_ipfs: Vec, pub quality_rating: u32, // 1-10 pub timestamp: u64, + /// Set once the record has been voided as a mistaken entry. Voided records + /// stay in the history but no longer count towards [`AssetStats`]. + pub voided: bool, + /// Set once an amendment sidecar has been filed for this record. + pub amended: bool, +} + +/// Audit sidecar describing a correction to an append-only [`MaintenanceRecord`]. +/// +/// The original record is never rewritten: [`AssetMaintenanceContract::amend_maintenance_record`] +/// stores one of these keyed by `(asset_id, record_id)` and flags the record as +/// `amended`, so both the originally filed values and the correction are +/// recoverable. The corrected cost and duration feed the [`AssetStats`] rollup. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaintenanceAmendment { + pub asset_id: u64, + pub record_id: u64, + pub amended_by: Address, + pub reason: String, + pub corrected_total_cost: i128, + pub corrected_duration_hours: u32, + pub corrected_description: String, + pub timestamp: u64, +} + +/// Audit sidecar describing why and by whom a [`MaintenanceRecord`] was voided. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaintenanceVoidRecord { + pub asset_id: u64, + pub record_id: u64, + pub voided_by: Address, + pub reason: String, + pub timestamp: u64, } #[contracttype] @@ -168,15 +211,20 @@ pub struct MaintenanceAlert { } #[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] pub enum DataKey { Admin, AssetRegistry, Provider(Address), - MaintenanceHistory(u64), // asset_id -> Vec - MaintenanceSchedule(u64), // asset_id -> ScheduledMaintenance - Warranty(u64), // asset_id -> WarrantyInfo - Alerts(u64), // asset_id -> Vec - AssetStats(u64), // asset_id -> AssetStats (downtime, total cost, etc.) + MaintenanceHistory(u64), // asset_id -> Vec + MaintenanceSchedule(u64), // asset_id -> ScheduledMaintenance + Warranty(u64), // asset_id -> WarrantyInfo + Alerts(u64), // asset_id -> Vec + AssetStats(u64), // asset_id -> AssetStats (downtime, total cost, etc.) + Paused, // bool: emergency pause on new maintenance logging + RecordAmendment(u64, u64), // (asset_id, record_id) -> MaintenanceAmendment + RecordVoid(u64, u64), // (asset_id, record_id) -> MaintenanceVoidRecord + DetokenizedAsset(u64), // asset_id -> bool: burned/detokenized asset } #[contracttype] @@ -200,13 +248,17 @@ impl AssetMaintenanceContract { /// authenticate against without an ABI change. A finer-grained model — /// letting the asset owner manage their own warranty, for instance — needs /// the asset-registry integration this contract does not have yet. - fn require_admin(env: &Env) { - let admin: Address = env - .storage() + fn admin(env: &Env) -> Address { + env.storage() .persistent() .get(&DataKey::Admin) - .unwrap_or_else(|| handle_error(&env, Error::NotInitialized)); + .unwrap_or_else(|| handle_error(env, Error::NotInitialized)) + } + + fn require_admin(env: &Env) -> Address { + let admin = Self::admin(env); admin.require_auth(); + admin } pub fn init(env: Env, admin: Address, registry: Address) { @@ -272,13 +324,33 @@ impl AssetMaintenanceContract { pub fn add_maintenance_record(env: Env, record: MaintenanceRecord) { // Auth check: provider must be the one adding the record record.provider.require_auth(); + Self::add_record_inner(&env, &record); + } + + /// Validates and persists a record whose provider has already been + /// authenticated. + /// + /// Shared by [`Self::add_maintenance_record`] and + /// [`Self::add_maintenance_records_batch`] so the latter can authenticate + /// each distinct provider exactly once — the host rejects a second + /// authorization entry for the same address within a single invocation. + fn add_record_inner(env: &Env, record: &MaintenanceRecord) { + // Emergency stop: while paused, no new maintenance may be logged. + if Self::is_paused(env.clone()) { + handle_error(env, Error::ContractPaused); + } + + // A detokenized (burned) asset must not accrue new maintenance. + if Self::is_asset_detokenized(env.clone(), record.asset_id) { + handle_error(env, Error::AssetDetokenized); + } // 1. Verify provider is registered and active let provider_data: ProviderProfile = env .storage() .persistent() .get(&DataKey::Provider(record.provider.clone())) - .unwrap_or_else(|| handle_error(&env, Error::ProviderNotRegistered)); + .unwrap_or_else(|| handle_error(env, Error::ProviderNotRegistered)); if !provider_data.is_active { panic!("provider is inactive"); } @@ -312,7 +384,7 @@ impl AssetMaintenanceContract { } // 3. Verify asset exists - if !Self::verify_asset_exists(&env, record.asset_id) { + if !Self::verify_asset_exists(env, record.asset_id) { panic!("asset does not exist"); } @@ -321,38 +393,18 @@ impl AssetMaintenanceContract { .storage() .persistent() .get::<_, Vec>(&DataKey::MaintenanceHistory(record.asset_id)) - .unwrap_or(Vec::new(&env)); + .unwrap_or(Vec::new(env)); history.push_back(record.clone()); env.storage() .persistent() .set(&DataKey::MaintenanceHistory(record.asset_id), &history); - ttl::extend_persistent(&env, &DataKey::MaintenanceHistory(record.asset_id)); + ttl::extend_persistent(env, &DataKey::MaintenanceHistory(record.asset_id)); - // 5. Update Asset Stats - let mut stats = env - .storage() - .persistent() - .get::<_, AssetStats>(&DataKey::AssetStats(record.asset_id)) - .unwrap_or(AssetStats { - total_cost: 0, - total_downtime_hours: 0, - service_count: 0, - health_score: 100, // Start with 100 - }); - - stats.total_cost += record.total_cost; - stats.total_downtime_hours += record.duration_hours as u64; - stats.service_count += 1; - // Recalculate health score (placeholder for now, will implement properly later) - stats.health_score = Self::calculate_health_score(&env, record.asset_id); - - env.storage() - .persistent() - .set(&DataKey::AssetStats(record.asset_id), &stats); - ttl::extend_persistent(&env, &DataKey::AssetStats(record.asset_id)); + // 5. Recompute Asset Stats from the live (non-voided) history. + Self::recompute_asset_stats(env, record.asset_id); // 6. Emit Event - events::maintenance_recorded(&env, &record); + events::maintenance_recorded(env, record); } /// Adds several maintenance records in a single invocation. @@ -366,8 +418,27 @@ impl AssetMaintenanceContract { handle_error(&env, Error::InvalidArguments); } + // Authenticate each distinct provider once. The host rejects a second + // authorization entry for the same address within a single invocation, + // so the per-record `require_auth` used by the single-record entrypoint + // cannot simply be repeated here. + let mut authenticated: Vec
= Vec::new(&env); + for record in records.iter() { + let mut seen = false; + for address in authenticated.iter() { + if address == record.provider { + seen = true; + break; + } + } + if !seen { + record.provider.require_auth(); + authenticated.push_back(record.provider.clone()); + } + } + for record in records.iter() { - Self::add_maintenance_record(env.clone(), record); + Self::add_record_inner(&env, &record); } } @@ -382,8 +453,18 @@ impl AssetMaintenanceContract { .unwrap_or(Vec::new(&env)) } + /// Schedules recurring maintenance for an asset. + /// + /// Rejected if the asset has been marked detokenized/burned: a burned asset + /// has no ongoing service obligation, and silently queueing work against it + /// wastes provider time and corrupts downstream scheduling. pub fn schedule_maintenance(env: Env, owner: Address, schedule: ScheduledMaintenance) { owner.require_auth(); + + if Self::is_asset_detokenized(env.clone(), schedule.asset_id) { + handle_error(&env, Error::AssetDetokenized); + } + Self::store_schedule(&env, &schedule); events::maintenance_scheduled(&env, &schedule); } @@ -641,22 +722,265 @@ impl AssetMaintenanceContract { false } - fn calculate_health_score(env: &Env, asset_id: u64) -> u32 { - let history = env + // ----------------------------------------------------------------------- + // Emergency pause (#1478) + // ----------------------------------------------------------------------- + + /// Pauses the contract. New maintenance records then reject with + /// [`Error::ContractPaused`] until [`Self::unpause`] is called. + pub fn pause(env: Env) { + let admin = Self::require_admin(&env); + + env.storage().persistent().set(&DataKey::Paused, &true); + ttl::extend_persistent(&env, &DataKey::Paused); + + events::contract_paused(&env, &admin); + } + + /// Resumes normal operation after a [`Self::pause`]. + pub fn unpause(env: Env) { + let admin = Self::require_admin(&env); + + env.storage().persistent().set(&DataKey::Paused, &false); + ttl::extend_persistent(&env, &DataKey::Paused); + + events::contract_unpaused(&env, &admin); + } + + /// Returns whether the contract is currently paused. Absent storage means + /// not paused, so an un-initialized-looking contract stays usable. + pub fn is_paused(env: Env) -> bool { + env.storage() + .persistent() + .get(&DataKey::Paused) + .unwrap_or(false) + } + + // ----------------------------------------------------------------------- + // Detokenized (burned) assets (#1477) + // ----------------------------------------------------------------------- + + /// Marks an asset as detokenized/burned. Such an asset accepts neither new + /// maintenance records nor new schedules until [`Self::reactivate_asset`]. + pub fn mark_asset_detokenized(env: Env, asset_id: u64) { + let admin = Self::require_admin(&env); + + env.storage() + .persistent() + .set(&DataKey::DetokenizedAsset(asset_id), &true); + ttl::extend_persistent(&env, &DataKey::DetokenizedAsset(asset_id)); + + events::asset_detokenized(&env, asset_id, &admin); + } + + /// Clears the detokenized flag, allowing maintenance again. + pub fn reactivate_asset(env: Env, asset_id: u64) { + let admin = Self::require_admin(&env); + + env.storage() + .persistent() + .set(&DataKey::DetokenizedAsset(asset_id), &false); + ttl::extend_persistent(&env, &DataKey::DetokenizedAsset(asset_id)); + + events::asset_reactivated(&env, asset_id, &admin); + } + + /// Returns whether an asset has been marked detokenized/burned. + pub fn is_asset_detokenized(env: Env, asset_id: u64) -> bool { + env.storage() + .persistent() + .get(&DataKey::DetokenizedAsset(asset_id)) + .unwrap_or(false) + } + + // ----------------------------------------------------------------------- + // Voiding and amending records (#1479) + // ----------------------------------------------------------------------- + + /// Voids a maintenance record that was filed by mistake. + /// + /// The record is *not* removed from the audit history; its `voided` flag is + /// set, a [`MaintenanceVoidRecord`] captures the reason and caller, and the + /// asset's derived stats are recomputed without it. + pub fn void_maintenance_record(env: Env, asset_id: u64, record_id: u64, reason: String) { + let admin = Self::require_admin(&env); + + let mut history = env .storage() .persistent() .get::<_, Vec>(&DataKey::MaintenanceHistory(asset_id)) - .unwrap_or(Vec::new(env)); + .unwrap_or_else(|| handle_error(&env, Error::RecordNotFound)); + + let index = Self::find_record_index(&history, record_id) + .unwrap_or_else(|| handle_error(&env, Error::RecordNotFound)); - if history.is_empty() { - return 100; + let mut record = history.get(index).unwrap(); + if record.voided { + handle_error(&env, Error::RecordAlreadyVoided); } + record.voided = true; + history.set(index, record); + + env.storage() + .persistent() + .set(&DataKey::MaintenanceHistory(asset_id), &history); + ttl::extend_persistent(&env, &DataKey::MaintenanceHistory(asset_id)); + + let void_record = MaintenanceVoidRecord { + asset_id, + record_id, + voided_by: admin.clone(), + reason, + timestamp: env.ledger().timestamp(), + }; + env.storage() + .persistent() + .set(&DataKey::RecordVoid(asset_id, record_id), &void_record); + ttl::extend_persistent(&env, &DataKey::RecordVoid(asset_id, record_id)); + + Self::recompute_asset_stats(&env, asset_id); - let mut total_quality = 0; - let mut improvement_score = 0; - let mut preventive_count = 0; + events::maintenance_record_voided(&env, asset_id, record_id, &admin); + } + + /// Amends a maintenance record's cost, duration, and description. + /// + /// The original values remain in the history; the correction is stored as a + /// [`MaintenanceAmendment`] and the derived stats are recomputed from the + /// corrected cost and duration. A voided record cannot be amended. + pub fn amend_maintenance_record( + env: Env, + asset_id: u64, + record_id: u64, + reason: String, + corrected_total_cost: i128, + corrected_duration_hours: u32, + corrected_description: String, + ) { + let admin = Self::require_admin(&env); + + if corrected_total_cost < 0 { + handle_error(&env, Error::InvalidArguments); + } + + let mut history = env + .storage() + .persistent() + .get::<_, Vec>(&DataKey::MaintenanceHistory(asset_id)) + .unwrap_or_else(|| handle_error(&env, Error::RecordNotFound)); + + let index = Self::find_record_index(&history, record_id) + .unwrap_or_else(|| handle_error(&env, Error::RecordNotFound)); + + if history.get(index).unwrap().voided { + handle_error(&env, Error::RecordVoided); + } + let mut record = history.get(index).unwrap(); + record.amended = true; + history.set(index, record); + env.storage() + .persistent() + .set(&DataKey::MaintenanceHistory(asset_id), &history); + ttl::extend_persistent(&env, &DataKey::MaintenanceHistory(asset_id)); + + let amendment = MaintenanceAmendment { + asset_id, + record_id, + amended_by: admin.clone(), + reason, + corrected_total_cost, + corrected_duration_hours, + corrected_description, + timestamp: env.ledger().timestamp(), + }; + env.storage() + .persistent() + .set(&DataKey::RecordAmendment(asset_id, record_id), &amendment); + ttl::extend_persistent(&env, &DataKey::RecordAmendment(asset_id, record_id)); + + Self::recompute_asset_stats(&env, asset_id); + + events::maintenance_record_amended(&env, asset_id, record_id, &admin); + } + + /// Returns the amendment sidecar for a record, if one has been filed. + pub fn get_maintenance_amendment( + env: Env, + asset_id: u64, + record_id: u64, + ) -> Option { + env.storage() + .persistent() + .get(&DataKey::RecordAmendment(asset_id, record_id)) + } + + /// Returns the void sidecar for a record, if it has been voided. + pub fn get_maintenance_void( + env: Env, + asset_id: u64, + record_id: u64, + ) -> Option { + env.storage() + .persistent() + .get(&DataKey::RecordVoid(asset_id, record_id)) + } + + /// Returns whether a specific record has been voided. + pub fn is_maintenance_record_voided(env: Env, asset_id: u64, record_id: u64) -> bool { + env.storage() + .persistent() + .get::<_, MaintenanceVoidRecord>(&DataKey::RecordVoid(asset_id, record_id)) + .is_some() + } + + /// Finds the position of `record_id` in `history`, if present. + fn find_record_index(history: &Vec, record_id: u64) -> Option { + for (index, record) in (0u32..).zip(history.iter()) { + if record.record_id == record_id { + return Some(index); + } + } + None + } + + /// Recomputes the [`AssetStats`] rollup for an asset from its live history. + /// + /// Voided records are excluded; amended records contribute the corrected + /// cost and duration. The health score reuses the historical weighting + /// (40% average quality, 30% preventive ratio, improvement bonus, overdue + /// penalty) but only over the live records. + fn recompute_asset_stats(env: &Env, asset_id: u64) -> AssetStats { + let history = env + .storage() + .persistent() + .get::<_, Vec>(&DataKey::MaintenanceHistory(asset_id)) + .unwrap_or(Vec::new(env)); + + let mut total_cost: i128 = 0; + let mut total_downtime: u64 = 0; + let mut service_count: u32 = 0; + let mut total_quality: u32 = 0; + let mut improvement_score: u32 = 0; + let mut preventive_count: u32 = 0; for record in history.iter() { + if record.voided { + continue; + } + + let (cost, downtime) = match env.storage().persistent().get::<_, MaintenanceAmendment>( + &DataKey::RecordAmendment(asset_id, record.record_id), + ) { + Some(amendment) => ( + amendment.corrected_total_cost, + amendment.corrected_duration_hours as u64, + ), + None => (record.total_cost, record.duration_hours as u64), + }; + + total_cost += cost; + total_downtime += downtime; + service_count += 1; total_quality += record.quality_rating; if record.condition_after > record.condition_before { improvement_score += 5; @@ -666,26 +990,47 @@ impl AssetMaintenanceContract { } } - let avg_quality = (total_quality * 10) / history.len(); // Scale to 100 - let preventive_ratio = (preventive_count * 100) / history.len(); + let health_score = if service_count == 0 { + 100 + } else { + let avg_quality = (total_quality * 10).checked_div(service_count).unwrap_or(0); + let preventive_ratio = (preventive_count * 100) + .checked_div(service_count) + .unwrap_or(0); + + // Weighted Score: 40% Quality, 30% Preventive Ratio, 30% Improvement + let mut score = + (avg_quality * 4 / 10) + (preventive_ratio * 3 / 10) + improvement_score; + + // Penalty for overdue + if let Some(schedule) = env + .storage() + .persistent() + .get::<_, ScheduledMaintenance>(&DataKey::MaintenanceSchedule(asset_id)) + { + if env.ledger().timestamp() > schedule.next_service_due { + let days_overdue = + (env.ledger().timestamp() - schedule.next_service_due) / 86400; + let penalty = (days_overdue as u32 * 2).min(30); + score = score.saturating_sub(penalty); + } + } - // Weighted Score: 40% Quality, 30% Preventive Ratio, 30% Improvement/Condition - let mut score = (avg_quality * 4 / 10) + (preventive_ratio * 3 / 10) + (improvement_score); + score.clamp(0, 100) + }; - // Penalty for overdue - if let Some(schedule) = env - .storage() + let stats = AssetStats { + total_cost, + total_downtime_hours: total_downtime, + service_count, + health_score, + }; + env.storage() .persistent() - .get::<_, ScheduledMaintenance>(&DataKey::MaintenanceSchedule(asset_id)) - { - if env.ledger().timestamp() > schedule.next_service_due { - let days_overdue = (env.ledger().timestamp() - schedule.next_service_due) / 86400; - let penalty = (days_overdue as u32 * 2).min(30); - score = score.saturating_sub(penalty); - } - } + .set(&DataKey::AssetStats(asset_id), &stats); + ttl::extend_persistent(env, &DataKey::AssetStats(asset_id)); - score.clamp(0, 100) + stats } fn verify_asset_exists(_env: &Env, _asset_id: u64) -> bool { diff --git a/contracts/asset-maintenance/src/proptests.rs b/contracts/asset-maintenance/src/proptests.rs index 4ed246aa..c3f1f439 100644 --- a/contracts/asset-maintenance/src/proptests.rs +++ b/contracts/asset-maintenance/src/proptests.rs @@ -86,6 +86,8 @@ fn record( documents_ipfs: vec![env, String::from_str(env, "ipfs://doc")], quality_rating: quality, timestamp: NOW, + voided: false, + amended: false, } } diff --git a/contracts/asset-maintenance/src/test.rs b/contracts/asset-maintenance/src/test.rs index a43a39d0..99c206aa 100644 --- a/contracts/asset-maintenance/src/test.rs +++ b/contracts/asset-maintenance/src/test.rs @@ -94,6 +94,8 @@ fn test_maintenance_lifecycle() { documents_ipfs: vec![&env, String::from_str(&env, "ipfs://abc")], quality_rating: 10, timestamp: env.ledger().timestamp(), + voided: false, + amended: false, }; client.add_maintenance_record(&record); @@ -251,6 +253,8 @@ fn test_unauthorized_maintenance_logging() { documents_ipfs: vec![&env], quality_rating: 10, timestamp: env.ledger().timestamp(), + voided: false, + amended: false, }; // Attempt to add record with unauthorized provider should fail @@ -461,6 +465,8 @@ fn test_aggregate_functions_single_record() { documents_ipfs: vec![&env, String::from_str(&env, "ipfs://abc")], quality_rating: 10, timestamp: env.ledger().timestamp(), + voided: false, + amended: false, }; client.add_maintenance_record(&record); diff --git a/contracts/asset-maintenance/src/tests_coverage.rs b/contracts/asset-maintenance/src/tests_coverage.rs index 504aa69a..9e495596 100644 --- a/contracts/asset-maintenance/src/tests_coverage.rs +++ b/contracts/asset-maintenance/src/tests_coverage.rs @@ -9,7 +9,7 @@ extern crate std; -use soroban_sdk::testutils::{Address as _, Ledger as _, LedgerInfo}; +use soroban_sdk::testutils::{Address as _, Events, Ledger as _, LedgerInfo}; use soroban_sdk::{vec, Address, Env, String, Vec}; use super::*; @@ -87,6 +87,8 @@ fn record(env: &Env, provider: &Address, record_id: u64, asset_id: u64) -> Maint documents_ipfs: vec![env, String::from_str(env, "ipfs://doc")], quality_rating: 8, timestamp: NOW, + voided: false, + amended: false, } } @@ -278,7 +280,10 @@ fn an_empty_maintenance_batch_is_rejected() { let ctx = setup(&env); let records: Vec = Vec::new(&env); - assert!(ctx.client.try_add_maintenance_records_batch(&records).is_err()); + assert!(ctx + .client + .try_add_maintenance_records_batch(&records) + .is_err()); } #[test] @@ -289,14 +294,20 @@ fn maintenance_history_is_empty_for_an_unknown_asset() { } #[test] -#[should_panic(expected = "provider not registered")] fn a_record_from_an_unregistered_provider_is_rejected() { let env = Env::default(); let ctx = setup(&env); let stranger = Address::generate(&env); - ctx.client - .add_maintenance_record(&record(&env, &stranger, 1, 7)); + // The failure is a typed `Error::ProviderNotRegistered` contract error, so + // it surfaces through `try_` rather than as a string panic. + let res = ctx + .client + .try_add_maintenance_record(&record(&env, &stranger, 1, 7)); + assert!( + res.is_err(), + "an unregistered provider must not file records" + ); } #[test] @@ -841,11 +852,10 @@ fn filing_a_claim_increments_the_claim_count() { } #[test] -#[should_panic(expected = "no warranty found")] fn claiming_against_a_missing_warranty_is_rejected() { let env = Env::default(); let ctx = setup(&env); - ctx.client.file_warranty_claim(&7, &500); + assert!(ctx.client.try_file_warranty_claim(&7, &500).is_err()); } #[test] @@ -954,12 +964,13 @@ fn acknowledging_an_out_of_range_alert_index_is_rejected() { } #[test] -#[should_panic(expected = "no alerts found")] fn acknowledging_an_alert_for_an_asset_without_any_is_rejected() { let env = Env::default(); let ctx = setup(&env); - ctx.client - .acknowledge_maintenance_alert(&7, &0, &Address::generate(&env)); + assert!(ctx + .client + .try_acknowledge_maintenance_alert(&7, &0, &Address::generate(&env)) + .is_err()); } #[test] @@ -1064,3 +1075,332 @@ fn parts_replaced_and_documents_round_trip() { assert_eq!(stored.documents_ipfs.len(), 1); assert_eq!(stored.technician_id, String::from_str(&env, "tech-01")); } + +// --------------------------------------------------------------------------- +// Emergency pause (#1478) +// --------------------------------------------------------------------------- + +#[test] +fn a_fresh_contract_is_not_paused() { + let env = Env::default(); + let ctx = setup(&env); + assert!(!ctx.client.is_paused()); +} + +#[test] +fn pausing_blocks_new_maintenance_records() { + let env = Env::default(); + let ctx = setup(&env); + ctx.client.pause(); + + let res = ctx + .client + .try_add_maintenance_record(&record(&env, &ctx.provider, 1, 7)); + assert!(res.is_err(), "a paused contract must reject new records"); + assert!(ctx.client.get_maintenance_history(&7).is_empty()); +} + +#[test] +fn unpausing_resumes_recording() { + let env = Env::default(); + let ctx = setup(&env); + ctx.client.pause(); + ctx.client.unpause(); + + assert!(!ctx.client.is_paused()); + ctx.client + .add_maintenance_record(&record(&env, &ctx.provider, 1, 7)); + assert_eq!(ctx.client.get_maintenance_history(&7).len(), 1); +} + +#[test] +fn pause_and_unpause_require_the_admins_authorization() { + let env = Env::default(); + let ctx = setup(&env); + env.set_auths(&[]); + + assert!(ctx.client.try_pause().is_err()); + assert!(!ctx.client.is_paused()); +} + +#[test] +fn pausing_does_not_block_scheduling_or_provider_management() { + let env = Env::default(); + let ctx = setup(&env); + ctx.client.pause(); + + // Pause is scoped to logging new maintenance, not to read/management paths. + ctx.client + .schedule_maintenance(&ctx.admin, &schedule(&env, &ctx.provider, 7, NOW + 500)); + assert!(ctx.client.get_upcoming_maintenance(&7).is_some()); +} + +// --------------------------------------------------------------------------- +// Detokenized / burned assets (#1477) +// --------------------------------------------------------------------------- + +#[test] +fn assets_are_not_detokenized_by_default() { + let env = Env::default(); + let ctx = setup(&env); + assert!(!ctx.client.is_asset_detokenized(&7)); +} + +#[test] +fn scheduling_maintenance_on_a_detokenized_asset_is_rejected() { + let env = Env::default(); + let ctx = setup(&env); + ctx.client.mark_asset_detokenized(&7); + + let res = ctx + .client + .try_schedule_maintenance(&ctx.admin, &schedule(&env, &ctx.provider, 7, NOW + 500)); + assert!( + res.is_err(), + "a burned asset must not accept a maintenance schedule" + ); + assert!(ctx.client.get_upcoming_maintenance(&7).is_none()); +} + +#[test] +fn recording_maintenance_on_a_detokenized_asset_is_rejected() { + let env = Env::default(); + let ctx = setup(&env); + ctx.client.mark_asset_detokenized(&7); + + let res = ctx + .client + .try_add_maintenance_record(&record(&env, &ctx.provider, 1, 7)); + assert!(res.is_err(), "a burned asset must not accrue maintenance"); + assert!(ctx.client.get_maintenance_history(&7).is_empty()); +} + +#[test] +fn reactivating_an_asset_allows_maintenance_again() { + let env = Env::default(); + let ctx = setup(&env); + ctx.client.mark_asset_detokenized(&7); + ctx.client.reactivate_asset(&7); + + assert!(!ctx.client.is_asset_detokenized(&7)); + ctx.client + .add_maintenance_record(&record(&env, &ctx.provider, 1, 7)); + assert_eq!(ctx.client.get_maintenance_history(&7).len(), 1); +} + +#[test] +fn detokenizing_requires_the_admins_authorization() { + let env = Env::default(); + let ctx = setup(&env); + env.set_auths(&[]); + + assert!(ctx.client.try_mark_asset_detokenized(&7).is_err()); + assert!(!ctx.client.is_asset_detokenized(&7)); +} + +// --------------------------------------------------------------------------- +// Voiding a mistaken record (#1479) +// --------------------------------------------------------------------------- + +fn reason(env: &Env) -> String { + String::from_str(env, "filed against the wrong asset") +} + +#[test] +fn voiding_a_record_flags_it_but_keeps_it_in_history() { + let env = Env::default(); + let ctx = setup(&env); + ctx.client + .add_maintenance_record(&record(&env, &ctx.provider, 1, 7)); + + ctx.client.void_maintenance_record(&7, &1, &reason(&env)); + + let history = ctx.client.get_maintenance_history(&7); + assert_eq!(history.len(), 1, "voiding must not delete audit evidence"); + assert!(history.get(0).unwrap().voided); + assert!(ctx.client.is_maintenance_record_voided(&7, &1)); +} + +#[test] +fn voiding_a_record_removes_it_from_the_derived_rollup() { + let env = Env::default(); + let ctx = setup(&env); + ctx.client + .add_maintenance_record(&record(&env, &ctx.provider, 1, 7)); + assert_eq!(ctx.client.calculate_total_maintenance_cost(&7), 100); + + ctx.client.void_maintenance_record(&7, &1, &reason(&env)); + + assert_eq!(ctx.client.calculate_total_maintenance_cost(&7), 0); + assert_eq!(ctx.client.calculate_asset_downtime(&7), 0); + assert_eq!(ctx.client.get_asset_stats(&7).service_count, 0); + assert_eq!(ctx.client.get_asset_health_score(&7), 100); +} + +#[test] +fn the_void_sidecar_records_the_reason_and_caller() { + let env = Env::default(); + let ctx = setup(&env); + ctx.client + .add_maintenance_record(&record(&env, &ctx.provider, 1, 7)); + + ctx.client.void_maintenance_record(&7, &1, &reason(&env)); + + let voided = ctx.client.get_maintenance_void(&7, &1).unwrap(); + assert_eq!(voided.record_id, 1); + assert_eq!(voided.voided_by, ctx.admin); + assert_eq!(voided.reason, reason(&env)); +} + +#[test] +fn voiding_a_record_twice_is_rejected() { + let env = Env::default(); + let ctx = setup(&env); + ctx.client + .add_maintenance_record(&record(&env, &ctx.provider, 1, 7)); + ctx.client.void_maintenance_record(&7, &1, &reason(&env)); + + assert!(ctx + .client + .try_void_maintenance_record(&7, &1, &reason(&env)) + .is_err()); +} + +#[test] +fn voiding_a_missing_record_is_rejected() { + let env = Env::default(); + let ctx = setup(&env); + assert!(ctx + .client + .try_void_maintenance_record(&7, &999, &reason(&env)) + .is_err()); +} + +#[test] +fn voiding_requires_the_admins_authorization() { + let env = Env::default(); + let ctx = setup(&env); + ctx.client + .add_maintenance_record(&record(&env, &ctx.provider, 1, 7)); + env.set_auths(&[]); + + assert!(ctx + .client + .try_void_maintenance_record(&7, &1, &reason(&env)) + .is_err()); + assert!(!ctx.client.is_maintenance_record_voided(&7, &1)); +} + +// --------------------------------------------------------------------------- +// Amending a mistaken record (#1479) +// --------------------------------------------------------------------------- + +#[test] +fn amending_a_record_updates_the_rollup_and_is_retrievable() { + let env = Env::default(); + let ctx = setup(&env); + ctx.client + .add_maintenance_record(&record(&env, &ctx.provider, 1, 7)); + + ctx.client.amend_maintenance_record( + &7, + &1, + &reason(&env), + &250, + &6, + &String::from_str(&env, "Corrected service"), + ); + + assert_eq!(ctx.client.calculate_total_maintenance_cost(&7), 250); + assert_eq!(ctx.client.calculate_asset_downtime(&7), 6); + assert!( + ctx.client + .get_maintenance_history(&7) + .get(0) + .unwrap() + .amended + ); + + let amendment = ctx.client.get_maintenance_amendment(&7, &1).unwrap(); + assert_eq!(amendment.corrected_total_cost, 250); + assert_eq!(amendment.corrected_duration_hours, 6); + assert_eq!(amendment.amended_by, ctx.admin); +} + +#[test] +fn amending_a_record_does_not_rewrite_the_original_values() { + let env = Env::default(); + let ctx = setup(&env); + ctx.client + .add_maintenance_record(&record(&env, &ctx.provider, 1, 7)); + + ctx.client.amend_maintenance_record( + &7, + &1, + &reason(&env), + &250, + &6, + &String::from_str(&env, "Corrected service"), + ); + + // The originally filed entry is preserved for audit. + let original = ctx.client.get_maintenance_history(&7).get(0).unwrap(); + assert_eq!(original.total_cost, 100); + assert_eq!(original.duration_hours, 4); +} + +#[test] +fn amending_a_voided_record_is_rejected() { + let env = Env::default(); + let ctx = setup(&env); + ctx.client + .add_maintenance_record(&record(&env, &ctx.provider, 1, 7)); + ctx.client.void_maintenance_record(&7, &1, &reason(&env)); + + let res = ctx.client.try_amend_maintenance_record( + &7, + &1, + &reason(&env), + &250, + &6, + &String::from_str(&env, "Corrected service"), + ); + assert!(res.is_err()); +} + +#[test] +fn amending_with_a_negative_cost_is_rejected() { + let env = Env::default(); + let ctx = setup(&env); + ctx.client + .add_maintenance_record(&record(&env, &ctx.provider, 1, 7)); + + let res = ctx.client.try_amend_maintenance_record( + &7, + &1, + &reason(&env), + &-1, + &4, + &String::from_str(&env, "bad"), + ); + assert!(res.is_err()); +} + +#[test] +fn amending_requires_the_admins_authorization() { + let env = Env::default(); + let ctx = setup(&env); + ctx.client + .add_maintenance_record(&record(&env, &ctx.provider, 1, 7)); + env.set_auths(&[]); + + let res = ctx.client.try_amend_maintenance_record( + &7, + &1, + &reason(&env), + &250, + &6, + &String::from_str(&env, "Corrected service"), + ); + assert!(res.is_err()); +} diff --git a/contracts/assetsup/README.md b/contracts/assetsup/README.md index f44afd33..036cf8fb 100644 --- a/contracts/assetsup/README.md +++ b/contracts/assetsup/README.md @@ -15,7 +15,7 @@ crate is under discussion in [SC-46]. - An asset has exactly one owner at any time. - An asset id is unique; re-registering an existing id fails with - `AssetAlreadyExists`. + `AlreadyExists`. - A retired asset cannot be transferred or updated. - For a tokenized asset, the sum of all holder balances equals the total token supply. @@ -117,7 +117,7 @@ Reads: `get_asset`, `get_asset_info`, `batch_get_asset_info`, | `assign_asset_to_branch` | admin or branch admin | | `get_branch`, `get_branch_assets` | — | -`assign_asset_to_branch` rejects an unknown branch (`BranchNotFound`) and an +`assign_asset_to_branch` rejects an unknown branch (`NotFound`) and an unknown asset (`AssetNotFound`), so an asset can never be pointed at a stale or misspelled branch id. diff --git a/contracts/assetsup/src/error.rs b/contracts/assetsup/src/error.rs index 35ac90d9..ef4db4b0 100644 --- a/contracts/assetsup/src/error.rs +++ b/contracts/assetsup/src/error.rs @@ -16,6 +16,13 @@ use soroban_sdk::{contracterror, panic_with_error, Env}; /// A published code is permanent. Retiring a variant leaves its number unused; /// it is never reassigned, because a backend built against the old meaning /// would silently misinterpret the new one. +/// +/// ## Size limit +/// +/// Soroban's contract spec caps a `#[contracterror]` enum at 50 cases. Several +/// narrow, single-subject variants were therefore folded into generic ones +/// (`AlreadyExists`, `NotFound`, `InvalidInput`) rather than pushing the enum +/// over the limit; the retired codes are left as gaps. #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)] @@ -46,16 +53,14 @@ pub enum Error { // --------------------------------------------------------------- /// No admin is stored, so the contract is not usable. AdminNotFound = 100, - /// An asset with this id is already registered. - AssetAlreadyExists = 101, + /// An entity with this id already exists. Generic across the asset, + /// branch, subscription, lease, policy, and claim registries. + AlreadyExists = 101, /// No asset is registered under this id. AssetNotFound = 102, - /// A branch with this id already exists. - BranchAlreadyExists = 103, - /// No branch is registered under this id. - BranchNotFound = 104, - /// A subscription already exists for this account. - SubscriptionAlreadyExists = 105, + // 103 `BranchAlreadyExists` retired — folded into `AlreadyExists`. + // 104 `BranchNotFound` retired — folded into `NotFound`. + // 105 `SubscriptionAlreadyExists` retired — folded into `AlreadyExists`. /// The payment supplied is missing or does not cover the amount due. InvalidPayment = 106, /// The contract has not been initialized with its metadata. @@ -70,8 +75,7 @@ pub enum Error { AssetNotTokenized = 121, /// The requested total supply is zero or negative. InvalidTokenSupply = 122, - /// The requested decimal precision is outside the permitted range. - InvalidTokenDecimals = 123, + // 123 `InvalidTokenDecimals` retired — folded into `InvalidInput`. /// The holder's balance is lower than the amount requested. InsufficientBalance = 124, /// Fewer tokens are locked than the amount being unlocked. @@ -100,8 +104,7 @@ pub enum Error { InsufficientVotingPower = 140, /// This address has already voted on this proposal. AlreadyVoted = 141, - /// No proposal exists under this id. - ProposalNotFound = 142, + // 142 `ProposalNotFound` retired — folded into `NotFound`. /// The proposal is malformed or in a state that does not allow this action. InvalidProposal = 143, /// The voting period for this proposal has closed. @@ -131,22 +134,17 @@ pub enum Error { // --------------------------------------------------------------- // Validation: 170–179 // --------------------------------------------------------------- - /// The asset name is empty or too long. - InvalidAssetName = 170, - /// The purchase value is negative. - InvalidPurchaseValue = 171, - /// The metadata URI is empty or malformed. - InvalidMetadataUri = 172, + // 170 `InvalidAssetName` retired — folded into `InvalidInput`. + // 171 `InvalidPurchaseValue` retired — folded into `InvalidInput`. + // 172 `InvalidMetadataUri` retired — folded into `InvalidInput`. /// The owner address is the zero address, or otherwise not usable. InvalidOwnerAddress = 173, // --------------------------------------------------------------- // Leasing and insurance: 180–199 // --------------------------------------------------------------- - /// No lease exists under this id. - LeaseNotFound = 180, - /// A lease already exists under this id. - LeaseAlreadyExists = 181, + // 180 `LeaseNotFound` retired — folded into `NotFound`. + // 181 `LeaseAlreadyExists` retired — folded into `AlreadyExists`. /// The asset is already out on an active lease. AssetAlreadyLeased = 182, /// The lease is not in a state that permits this action. @@ -172,10 +170,15 @@ pub enum Error { InvalidPolicyTerms = 192, /// The claim amount is zero, negative, or above the policy coverage. InvalidClaimAmount = 193, - /// A policy with this id already exists. - PolicyAlreadyExists = 194, - /// A claim with this id already exists. - ClaimAlreadyExists = 195, + // 194 `PolicyAlreadyExists` retired — folded into `AlreadyExists`. + // 195 `ClaimAlreadyExists` retired — folded into `AlreadyExists`. + + // --------------------------------------------------------------- + // Upgrade: 196–199 + // --------------------------------------------------------------- + /// The WASM hash passed to `upgrade` is not on the admin-approved allowlist. + /// The admin must `approve_wasm_hash` in a separate transaction first. + WasmHashNotApproved = 196, } pub fn handle_error(env: &Env, error: Error) -> ! { @@ -185,7 +188,7 @@ pub fn handle_error(env: &Env, error: Error) -> ! { #[allow(dead_code)] pub fn dummy_function(_env: Env, asset_exists: bool) -> Result<(), Error> { if asset_exists { - Err(Error::AssetAlreadyExists) + Err(Error::AlreadyExists) } else { Ok(()) } @@ -200,7 +203,7 @@ mod tests { fn test_dummy_function_asset_exists() { let env = Env::default(); let result = dummy_function(env.clone(), true); - assert_eq!(result, Err(Error::AssetAlreadyExists)); + assert_eq!(result, Err(Error::AlreadyExists)); } #[test] @@ -230,17 +233,13 @@ mod tests { // identifies assetsup as its origin. let codes = [ Error::AdminNotFound as u32, - Error::AssetAlreadyExists as u32, + Error::AlreadyExists as u32, Error::AssetNotFound as u32, - Error::BranchAlreadyExists as u32, - Error::BranchNotFound as u32, - Error::SubscriptionAlreadyExists as u32, Error::InvalidPayment as u32, Error::ContractNotInitialized as u32, Error::AssetAlreadyTokenized as u32, Error::AssetNotTokenized as u32, Error::InvalidTokenSupply as u32, - Error::InvalidTokenDecimals as u32, Error::InsufficientBalance as u32, Error::InsufficientLockedTokens as u32, Error::TokensAreLocked as u32, @@ -252,7 +251,6 @@ mod tests { Error::DuplicateTokenSymbol as u32, Error::InsufficientVotingPower as u32, Error::AlreadyVoted as u32, - Error::ProposalNotFound as u32, Error::InvalidProposal as u32, Error::VotingPeriodEnded as u32, Error::NoDividendsToClaim as u32, @@ -261,26 +259,20 @@ mod tests { Error::DetokenizationAlreadyProposed as u32, Error::InvalidValuation as u32, Error::DetokenizationCooldownActive as u32, - Error::InvalidAssetName as u32, - Error::InvalidPurchaseValue as u32, - Error::InvalidMetadataUri as u32, Error::InvalidOwnerAddress as u32, - Error::LeaseNotFound as u32, - Error::LeaseAlreadyExists as u32, Error::AssetAlreadyLeased as u32, Error::InvalidLeaseStatus as u32, Error::LeaseAlreadyStarted as u32, Error::LeaseNotExpired as u32, Error::InvalidTimestamps as u32, + Error::ExpiryReminderNotDue as u32, Error::PolicyNotFound as u32, Error::ClaimNotFound as u32, Error::InvalidPolicyStatus as u32, Error::InvalidClaimStatus as u32, Error::InvalidPolicyTerms as u32, Error::InvalidClaimAmount as u32, - Error::PolicyAlreadyExists as u32, - Error::ClaimAlreadyExists as u32, - Error::ExpiryReminderNotDue as u32, + Error::WasmHashNotApproved as u32, ]; for code in codes { @@ -290,4 +282,66 @@ mod tests { ); } } + + #[test] + fn the_error_enum_fits_the_contract_spec_limit() { + // The contract spec caps a `#[contracterror]` enum at 50 cases; going + // over makes the whole crate fail to compile. Keep this guard so the + // limit is caught here instead of in a cryptic macro panic. + let cases = [ + Error::AlreadyInitialized as u32, + Error::NotInitialized as u32, + Error::Unauthorized as u32, + Error::InvalidInput as u32, + Error::NotFound as u32, + Error::ContractPaused as u32, + Error::MathOverflow as u32, + Error::MathUnderflow as u32, + Error::AdminNotFound as u32, + Error::AlreadyExists as u32, + Error::AssetNotFound as u32, + Error::InvalidPayment as u32, + Error::ContractNotInitialized as u32, + Error::AssetAlreadyTokenized as u32, + Error::AssetNotTokenized as u32, + Error::InvalidTokenSupply as u32, + Error::InsufficientBalance as u32, + Error::InsufficientLockedTokens as u32, + Error::TokensAreLocked as u32, + Error::TransferRestrictionFailed as u32, + Error::NotWhitelisted as u32, + Error::AccreditedInvestorRequired as u32, + Error::GeographicRestriction as u32, + Error::HolderNotFound as u32, + Error::DuplicateTokenSymbol as u32, + Error::InsufficientVotingPower as u32, + Error::AlreadyVoted as u32, + Error::InvalidProposal as u32, + Error::VotingPeriodEnded as u32, + Error::NoDividendsToClaim as u32, + Error::InvalidDividendAmount as u32, + Error::DetokenizationNotApproved as u32, + Error::DetokenizationAlreadyProposed as u32, + Error::InvalidValuation as u32, + Error::DetokenizationCooldownActive as u32, + Error::InvalidOwnerAddress as u32, + Error::AssetAlreadyLeased as u32, + Error::InvalidLeaseStatus as u32, + Error::LeaseAlreadyStarted as u32, + Error::LeaseNotExpired as u32, + Error::InvalidTimestamps as u32, + Error::ExpiryReminderNotDue as u32, + Error::PolicyNotFound as u32, + Error::ClaimNotFound as u32, + Error::InvalidPolicyStatus as u32, + Error::InvalidClaimStatus as u32, + Error::InvalidPolicyTerms as u32, + Error::InvalidClaimAmount as u32, + Error::WasmHashNotApproved as u32, + ]; + assert!( + cases.len() <= 50, + "the assetsup Error enum exceeds the 50-case contract spec limit" + ); + } } diff --git a/contracts/assetsup/src/events.rs b/contracts/assetsup/src/events.rs index f4419d4a..48ad276b 100644 --- a/contracts/assetsup/src/events.rs +++ b/contracts/assetsup/src/events.rs @@ -404,6 +404,26 @@ pub struct ContractMigrated { pub timestamp: u64, } +/// A WASM hash was added to the upgrade allowlist. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WasmHashApproved { + #[topic] + pub admin: Address, + pub wasm_hash: BytesN<32>, + pub timestamp: u64, +} + +/// A WASM hash was removed from the upgrade allowlist. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WasmHashRevoked { + #[topic] + pub admin: Address, + pub wasm_hash: BytesN<32>, + pub timestamp: u64, +} + // --------------------------------------------------------------------------- // Emission helpers // --------------------------------------------------------------------------- @@ -765,3 +785,21 @@ pub fn contract_migrated(env: &Env, from_version: u32, to_version: u32) { } .publish(env); } + +pub fn wasm_hash_approved(env: &Env, admin: &Address, wasm_hash: &BytesN<32>) { + WasmHashApproved { + admin: admin.clone(), + wasm_hash: wasm_hash.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} + +pub fn wasm_hash_revoked(env: &Env, admin: &Address, wasm_hash: &BytesN<32>) { + WasmHashRevoked { + admin: admin.clone(), + wasm_hash: wasm_hash.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); +} diff --git a/contracts/assetsup/src/insurance.rs b/contracts/assetsup/src/insurance.rs index 42446cfe..21f9df16 100644 --- a/contracts/assetsup/src/insurance.rs +++ b/contracts/assetsup/src/insurance.rs @@ -112,7 +112,7 @@ pub fn create_policy(env: Env, policy: InsurancePolicy) -> Result<(), Error> { // Check if policy already exists if store.has(&key) { - return Err(Error::PolicyAlreadyExists); + return Err(Error::AlreadyExists); } // Store the policy @@ -308,7 +308,7 @@ pub fn file_insurance_claim(env: Env, claim: InsuranceClaim) -> Result<(), Error // Verify claim doesn't already exist let claim_key = DataKey::Claim(claim.claim_id.clone()); if store.has(&claim_key) { - return Err(Error::ClaimAlreadyExists); + return Err(Error::AlreadyExists); } // Verify claim status is Submitted diff --git a/contracts/assetsup/src/lease.rs b/contracts/assetsup/src/lease.rs index 59299633..cc636132 100644 --- a/contracts/assetsup/src/lease.rs +++ b/contracts/assetsup/src/lease.rs @@ -42,7 +42,7 @@ fn load_lease(env: &Env, lease_id: &BytesN<32>) -> Result { env.storage() .persistent() .get(&DataKey::Lease(lease_id.clone())) - .ok_or(Error::LeaseNotFound) + .ok_or(Error::NotFound) } fn save_lease(env: &Env, lease: &Lease) { @@ -102,7 +102,7 @@ pub fn create_lease( .persistent() .has(&DataKey::Lease(lease_id.clone())) { - return Err(Error::LeaseAlreadyExists); + return Err(Error::AlreadyExists); } // Asset must not already have an Active lease diff --git a/contracts/assetsup/src/lib.rs b/contracts/assetsup/src/lib.rs index 1a98c694..fbf4b57e 100644 --- a/contracts/assetsup/src/lib.rs +++ b/contracts/assetsup/src/lib.rs @@ -11,7 +11,7 @@ //! ## Invariants //! //! - An asset has exactly one owner at any time. -//! - An asset id is unique; re-registering fails with `Error::AssetAlreadyExists`. +//! - An asset id is unique; re-registering fails with `Error::AlreadyExists`. //! - A retired asset cannot be transferred or updated. //! - For a tokenized asset, holder balances sum to the total token supply. //! @@ -75,6 +75,9 @@ pub enum DataKey { PendingAdmin, ScheduledTransfer(BytesN<32>), PendingApproval(BytesN<32>), + /// A WASM hash the admin has approved for `upgrade`. Only hashes present + /// and set to `true` here may be swapped in. See `contracts/UPGRADE.md`. + ApprovedWasmHash(BytesN<32>), } #[contract] @@ -213,7 +216,7 @@ impl AssetUpContract { // Check if asset already exists if store.has(&key) { - return Err(Error::AssetAlreadyExists); + return Err(Error::AlreadyExists); } // Store asset @@ -264,17 +267,17 @@ impl AssetUpContract { fn validate_asset(env: &Env, asset: &asset::Asset) -> Result<(), Error> { // Validate asset name length (3-100 characters) if asset.name.len() < 3 || asset.name.len() > 100 { - return Err(Error::InvalidAssetName); + return Err(Error::InvalidInput); } // Validate purchase value is non-negative if asset.purchase_value < 0 { - return Err(Error::InvalidPurchaseValue); + return Err(Error::InvalidInput); } // Validate metadata URI format (basic check for IPFS hash format) if !asset.metadata_uri.is_empty() && !Self::is_valid_metadata_uri(&asset.metadata_uri) { - return Err(Error::InvalidMetadataUri); + return Err(Error::InvalidInput); } // Validate owner address is not zero address @@ -337,7 +340,7 @@ impl AssetUpContract { if let Some(metadata_uri) = new_metadata_uri { if !metadata_uri.is_empty() && !Self::is_valid_metadata_uri(&metadata_uri) { - return Err(Error::InvalidMetadataUri); + return Err(Error::InvalidInput); } asset.metadata_uri = metadata_uri; } @@ -563,7 +566,7 @@ impl AssetUpContract { let key = branch::DataKey::Branch(branch.id.clone()); let store = env.storage().persistent(); if store.has(&key) { - return Err(Error::BranchAlreadyExists); + return Err(Error::AlreadyExists); } store.set(&key, &branch); ttl::extend_persistent(&env, &key); @@ -597,7 +600,7 @@ impl AssetUpContract { let asset_key = asset::DataKey::Asset(asset_id.clone()); let store = env.storage().persistent(); - let branch: branch::Branch = store.get(&branch_key).ok_or(Error::BranchNotFound)?; + let branch: branch::Branch = store.get(&branch_key).ok_or(Error::NotFound)?; if !store.has(&asset_key) { return Err(Error::AssetNotFound); } @@ -608,8 +611,7 @@ impl AssetUpContract { } let list_key = branch::DataKey::AssetList(branch_id.clone()); - let mut assets: Vec> = - store.get(&list_key).unwrap_or_else(|| Vec::new(&env)); + let mut assets: Vec> = store.get(&list_key).unwrap_or_else(|| Vec::new(&env)); if !assets.iter().any(|x| x == asset_id) { assets.push_back(asset_id.clone()); store.set(&list_key, &assets); @@ -630,7 +632,7 @@ impl AssetUpContract { ttl::extend_persistent(&env, &key); Ok(b) } - None => Err(Error::BranchNotFound), + None => Err(Error::NotFound), } } @@ -786,6 +788,12 @@ impl AssetUpContract { let admin = Self::get_admin(env.clone())?; admin.require_auth(); + // Only a hash the admin has explicitly approved may be swapped in. This + // turns a single compromised admin key into two separately observable + // transactions: the approval (which can be monitored and alarmed on + // off-chain) and the swap. + Self::require_wasm_hash_approved(&env, &new_wasm_hash)?; + env.deployer() .update_current_contract_wasm(new_wasm_hash.clone()); @@ -793,6 +801,63 @@ impl AssetUpContract { Ok(()) } + /// Adds a WASM hash to the upgrade allowlist. + /// + /// Admin-gated. Approving is deliberately a separate transaction from + /// [`Self::upgrade`] so that the intent to install a given build is + /// auditable on its own. + pub fn approve_wasm_hash(env: Env, wasm_hash: BytesN<32>) -> Result<(), Error> { + let admin = Self::get_admin(env.clone())?; + admin.require_auth(); + + env.storage() + .persistent() + .set(&DataKey::ApprovedWasmHash(wasm_hash.clone()), &true); + ttl::extend_persistent(&env, &DataKey::ApprovedWasmHash(wasm_hash.clone())); + + events::wasm_hash_approved(&env, &admin, &wasm_hash); + Ok(()) + } + + /// Removes a WASM hash from the upgrade allowlist. + /// + /// Admin-gated. Revoking is a no-op for a hash that was never approved; + /// the flag is simply written `false`. + pub fn revoke_wasm_hash(env: Env, wasm_hash: BytesN<32>) -> Result<(), Error> { + let admin = Self::get_admin(env.clone())?; + admin.require_auth(); + + env.storage() + .persistent() + .set(&DataKey::ApprovedWasmHash(wasm_hash.clone()), &false); + ttl::extend_persistent(&env, &DataKey::ApprovedWasmHash(wasm_hash.clone())); + + events::wasm_hash_revoked(&env, &admin, &wasm_hash); + Ok(()) + } + + /// Returns whether a WASM hash is currently approved for `upgrade`. + pub fn is_wasm_hash_approved(env: Env, wasm_hash: BytesN<32>) -> bool { + env.storage() + .persistent() + .get(&DataKey::ApprovedWasmHash(wasm_hash)) + .unwrap_or(false) + } + + /// Rejects a WASM hash that is not on the admin-approved allowlist. + fn require_wasm_hash_approved(env: &Env, wasm_hash: &BytesN<32>) -> Result<(), Error> { + if env + .storage() + .persistent() + .get(&DataKey::ApprovedWasmHash(wasm_hash.clone())) + .unwrap_or(false) + { + Ok(()) + } else { + Err(Error::WasmHashNotApproved) + } + } + /// Brings stored data up to the layout this build expects. /// /// Idempotent: running it when already current is a no-op, so a retried or @@ -1306,10 +1371,7 @@ impl AssetUpContract { /// Emit an on-chain expiry reminder for a policy nearing its end date /// (permissionless; only fires inside the reminder window) - pub fn emit_insurance_expiry_reminder( - env: Env, - policy_id: BytesN<32>, - ) -> Result<(), Error> { + pub fn emit_insurance_expiry_reminder(env: Env, policy_id: BytesN<32>) -> Result<(), Error> { Self::require_not_paused(&env)?; insurance::emit_expiry_reminder(env, policy_id) diff --git a/contracts/assetsup/src/tests/asset.rs b/contracts/assetsup/src/tests/asset.rs index 59be8229..b1ae9326 100644 --- a/contracts/assetsup/src/tests/asset.rs +++ b/contracts/assetsup/src/tests/asset.rs @@ -81,7 +81,7 @@ fn test_register_asset_unauthorized() { } #[test] -#[should_panic(expected = "Error(Contract, #170)")] +#[should_panic(expected = "Error(Contract, #4)")] fn test_register_asset_invalid_name_too_short() { let env = create_env(); let (admin, user1, _, _) = create_mock_addresses(&env); @@ -98,7 +98,7 @@ fn test_register_asset_invalid_name_too_short() { } #[test] -#[should_panic(expected = "Error(Contract, #171)")] +#[should_panic(expected = "Error(Contract, #4)")] fn test_register_asset_invalid_purchase_value() { let env = create_env(); let (admin, user1, _, _) = create_mock_addresses(&env); @@ -408,7 +408,7 @@ fn test_register_branch_and_assign_asset() { } #[test] -#[should_panic(expected = "Error(Contract, #104)")] +#[should_panic(expected = "Error(Contract, #5)")] fn test_assign_asset_to_unknown_branch_fails() { let env = create_env(); let (admin, user1, _, _) = create_mock_addresses(&env); diff --git a/contracts/assetsup/src/tests/pause.rs b/contracts/assetsup/src/tests/pause.rs index 3b4492af..9991a7d3 100644 --- a/contracts/assetsup/src/tests/pause.rs +++ b/contracts/assetsup/src/tests/pause.rs @@ -61,6 +61,14 @@ const PAUSE_EXEMPT: &[(&str, &str)] = &[ "upgrade", "an upgrade is how you fix the incident that caused the pause", ), + ( + "approve_wasm_hash", + "part of the upgrade control; an incident fix may need an approval before upgrade can run", + ), + ( + "revoke_wasm_hash", + "part of the upgrade control; revoking a bad approval must work while paused", + ), ( "migrate", "must run after upgrade to bring storage up to date", diff --git a/contracts/assetsup/src/tests/pause_ttl.rs b/contracts/assetsup/src/tests/pause_ttl.rs index 6d099fec..a408d94b 100644 --- a/contracts/assetsup/src/tests/pause_ttl.rs +++ b/contracts/assetsup/src/tests/pause_ttl.rs @@ -137,4 +137,4 @@ fn rotating_the_admin_does_not_clear_the_pause() { assert_eq!(client.get_admin(), successor); assert!(client.is_paused(), "the pause must survive the rotation"); -} \ No newline at end of file +} diff --git a/contracts/assetsup/src/tests/ttl.rs b/contracts/assetsup/src/tests/ttl.rs index f0299bb3..f07ceafe 100644 --- a/contracts/assetsup/src/tests/ttl.rs +++ b/contracts/assetsup/src/tests/ttl.rs @@ -142,12 +142,11 @@ fn bump_asset_ttl_keeps_an_inactive_tokenized_asset_alive() { advance_ledgers(&env, 70 * LEDGERS_PER_DAY); } - let tokenized = client - .get_tokenized_asset(&1u64) - .expect("a bumped asset must still be readable"); + // A bumped asset must still be readable. + let tokenized = client.get_tokenized_asset(&1u64); assert_eq!(tokenized.total_supply, 1000); // The holder balance entry must survive too, not just the asset record. - assert_eq!(client.get_token_balance(&1u64, &tokenizer).unwrap(), 1000); + assert_eq!(client.get_token_balance(&1u64, &tokenizer), 1000); } #[test] diff --git a/contracts/assetsup/src/tests/upgrade.rs b/contracts/assetsup/src/tests/upgrade.rs index 2b7682b0..91b4038b 100644 --- a/contracts/assetsup/src/tests/upgrade.rs +++ b/contracts/assetsup/src/tests/upgrade.rs @@ -126,6 +126,80 @@ fn migration_preserves_the_whole_registry() { assert!(client.is_authorized_registrar(&admin)); } +// --------------------------------------------------------------------------- +// Upgrade allowlist (#1496) +// --------------------------------------------------------------------------- + +#[test] +fn upgrade_of_an_unapproved_wasm_hash_is_rejected() { + let env = create_env(); + let admin = Address::generate(&env); + let client = initialize_contract(&env, &admin); + let hash = placeholder_wasm_hash(&env); + + let res = client.try_upgrade(&hash); + assert!( + res.is_err(), + "an unapproved WASM hash must not be installable" + ); +} + +#[test] +fn approving_a_hash_puts_it_on_the_allowlist() { + let env = create_env(); + let admin = Address::generate(&env); + let client = initialize_contract(&env, &admin); + let hash = placeholder_wasm_hash(&env); + + assert!(!client.is_wasm_hash_approved(&hash)); + client.approve_wasm_hash(&hash); + assert!(client.is_wasm_hash_approved(&hash)); +} + +#[test] +fn revoking_a_hash_takes_it_off_the_allowlist() { + let env = create_env(); + let admin = Address::generate(&env); + let client = initialize_contract(&env, &admin); + let hash = placeholder_wasm_hash(&env); + + client.approve_wasm_hash(&hash); + client.revoke_wasm_hash(&hash); + + assert!(!client.is_wasm_hash_approved(&hash)); +} + +#[test] +fn approving_a_wasm_hash_requires_the_admins_authorization() { + let env = create_env(); + let admin = Address::generate(&env); + let client = initialize_contract(&env, &admin); + let hash = placeholder_wasm_hash(&env); + + env.set_auths(&[]); + assert!(client.try_approve_wasm_hash(&hash).is_err()); + assert!( + !client.is_wasm_hash_approved(&hash), + "an unauthorized approval must not take effect" + ); +} + +#[test] +fn the_allowlist_gate_reports_its_own_error() { + // The allowlist is checked before `update_current_contract_wasm`, so the + // exact contract error is observable even though the native test + // environment cannot run the WASM swap itself. + let env = create_env(); + let contract_id = env.register(AssetUpContract, ()); + let hash = placeholder_wasm_hash(&env); + + let result = env.as_contract(&contract_id, || { + AssetUpContract::require_wasm_hash_approved(&env, &hash) + }); + + assert_eq!(result, Err(crate::error::Error::WasmHashNotApproved)); +} + #[test] fn migrate_requires_the_admins_authorization() { let env = create_env();