Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion apexchainx_calculator/src/api_stability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ pub fn canonical_field_counts() -> [(&'static str, u32); 31] {
("FailureCode", 3),
("FailureSchema", 2),
("HealthcheckResult", 3),
("ConfigBundle", 2),
("ConfigBundle", 3),
("AuditState", 10),
("ContractInfo", 11),
("HistoryPage", 3),
Expand Down
33 changes: 33 additions & 0 deletions apexchainx_calculator/src/calculation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ pub fn calculate_sla(

/// Recalculates SLA deterministically without mutating state or emitting events.
/// Can be called by anyone for audit and verification purposes.
///
/// Applies duplicate-detection and replay logic read-only:
/// - Returns stored `SLAResult` if `outage_id` exists under unchanged `config_version_hash` and matching inputs.
/// - Returns `Err(SLAError::DuplicateOutageInput)` if inputs conflict under unchanged `config_version_hash`.
/// - Computes fresh result if `outage_id` is new or config hash changed.
pub fn calculate_sla_view(
env: &Env,
outage_id: Symbol,
Expand All @@ -158,6 +163,34 @@ pub fn calculate_sla_view(
crate::SLACalculatorContract::check_version(env)?;
let cfg = crate::SLACalculatorContract::load_config(env, &severity)?;
let config_version_hash = crate::SLACalculatorContract::compute_config_version_hash(env)?;

let history: Vec<SLAResult> = env
.storage()
.instance()
.get(&HISTORY_KEY)
.unwrap_or_else(|| Vec::new(env));

let mut existing: Option<SLAResult> = None;
let mut stored_for_outage: u32 = 0;
for i in 0..history.len() {
let entry = history.get(i).unwrap();
if entry.outage_id == outage_id {
stored_for_outage += 1;
existing = Some(entry);
}
}
if let Some(prev) = existing {
if prev.config_version_hash == config_version_hash {
if prev.mttr_minutes != mttr_minutes || prev.threshold_minutes != cfg.threshold_minutes {
return Err(SLAError::DuplicateOutageInput);
}
return Ok(prev);
}
if stored_for_outage >= MAX_RECALCS_PER_OUTAGE {
return Err(SLAError::OutageRecalcLimit);
}
}

compute_result(
outage_id,
mttr_minutes,
Expand Down
6 changes: 3 additions & 3 deletions apexchainx_calculator/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! SLA configurations. It enforces validation, cross-severity ordering,
//! and freeze-state gating for all config mutations.

use soroban_sdk::{symbol_short, Env, Map, Symbol, Vec};
use soroban_sdk::{Env, Map, Symbol, Vec};

use crate::{
config_freeze, config_metadata, SLAConfig, SLAConfigEntry, SLAConfigSnapshot, SLAError, CONFIG_KEY,
Expand Down Expand Up @@ -75,7 +75,7 @@ pub fn get_config_snapshot(env: &Env) -> Result<SLAConfigSnapshot, SLAError> {
}

Ok(SLAConfigSnapshot {
version: symbol_short!("v1"),
version: crate::CONFIG_SNAPSHOT_VERSION,
entries,
})
}
Expand Down Expand Up @@ -201,7 +201,7 @@ pub fn get_custom_config_snapshot(env: &Env) -> Result<SLAConfigSnapshot, SLAErr
}

Ok(SLAConfigSnapshot {
version: symbol_short!("v1"),
version: crate::CONFIG_SNAPSHOT_VERSION,
entries,
})
}
Expand Down
17 changes: 17 additions & 0 deletions apexchainx_calculator/src/config_bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ pub struct ConfigBundle {
pub snapshot: SLAConfigSnapshot,
/// Result schema descriptor with symbol mappings.
pub schema: SLAResultSchema,
/// Config version hash corresponding to the snapshot for duplicate detection.
pub config_version_hash: u64,
}

#[cfg(test)]
Expand Down Expand Up @@ -109,6 +111,21 @@ mod tests {
);
}

#[test]
fn test_config_bundle_hash_matches_get_config_version_hash() {
let (_env, client, _admin) = setup();

let bundle = client
.get_config_bundle()
.expect("bundle must be available after init");
let hash = client.get_config_version_hash();

assert_eq!(
bundle.config_version_hash, hash,
"Bundle config_version_hash must equal get_config_version_hash",
);
}

#[test]
fn test_config_bundle_reflects_admin_config_updates() {
let (_env, client, admin) = setup();
Expand Down
49 changes: 26 additions & 23 deletions apexchainx_calculator/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ pub(crate) const STORAGE_VERSION: u32 = 1;
/// Incremented when result encoding changes in a breaking way.
pub(crate) const RESULT_SCHEMA_VERSION: u32 = 1;

/// Version label of the SLAConfigSnapshot schema exposed via get_config_snapshot() and get_custom_config_snapshot().
/// Incremented (e.g. "v1" -> "v2") when the snapshot structure or entry format changes.
pub(crate) const CONFIG_SNAPSHOT_VERSION: Symbol = symbol_short!("v1");

/// Number of named fields in `SLAResult`.
///
/// This constant is the migration guardrail for `get_result_schema()`.
Expand Down Expand Up @@ -1058,16 +1062,26 @@ impl SLACalculatorContract {
// Initialisation
// -------------------------------------------------------------------

/// Deploy the contract.
/// `admin` – may update config, pause/unpause, and assign the operator.
/// `operator` – may call `calculate_sla`.
/// Deploy and initialize the contract.
///
/// # Roles
/// - `admin` – may update config, pause/unpause, and assign/transfer the operator role.
/// - `operator` – may call `calculate_sla`.
///
/// # Role Separation & Single-Address Mode
/// Both `admin` and `operator` authorizations are verified during initialization.
/// `admin` and `operator` may be distinct addresses (supporting separation of duties)
/// or the same address (`admin == operator`, supporting single-address / merged-role deployments).
/// When `admin == operator`, both role capabilities are assigned to that single address.
pub fn initialize(env: Env, admin: Address, operator: Address) -> Result<(), SLAError> {
if env.storage().instance().has(&ADMIN_KEY) {
return Err(SLAError::AlreadyInitialized);
}

admin.require_auth();
operator.require_auth();
if admin != operator {
operator.require_auth();
}

env.storage().instance().set(&ADMIN_KEY, &admin);
env.storage().instance().set(&OPERATOR_KEY, &operator); // #28
Expand Down Expand Up @@ -1850,8 +1864,13 @@ impl SLACalculatorContract {
/// contract is initialised and on the current storage version.
pub fn get_config_bundle(env: Env) -> Result<Option<ConfigBundle>, SLAError> {
let snapshot = Self::get_config_snapshot(env.clone())?;
let schema = Self::get_result_schema(env)?;
Ok(Some(ConfigBundle { snapshot, schema }))
let schema = Self::get_result_schema(env.clone())?;
let config_version_hash = Self::compute_config_version_hash(&env)?;
Ok(Some(ConfigBundle {
snapshot,
schema,
config_version_hash,
}))
}

/// Returns the full audit state including roles, config, stats, and history.
Expand Down Expand Up @@ -2219,23 +2238,7 @@ impl SLACalculatorContract {
severity: Symbol,
mttr_minutes: u32,
) -> Result<SLAResult, SLAError> {
Self::check_version(&env)?;
// We bypass pause and operator checks to allow continuous, public verification
let cfg = Self::load_config(&env, &severity)?;
let config_version_hash = Self::compute_config_version_hash(&env)?;

// Delegate to pure internal math without mutating state or emitting events.

// Use the current ledger timestamp so the view result matches the mutating
// path for the same inputs executed in the same ledger, while still avoiding
// any state writes or event emission.
Self::compute_result(
outage_id,
mttr_minutes,
&cfg,
config_version_hash,
env.ledger().timestamp(),
)
crate::calculation::calculate_sla_view(&env, outage_id, severity, mttr_minutes)
}

// -------------------------------------------------------------------
Expand Down
37 changes: 36 additions & 1 deletion apexchainx_calculator/src/schema_migration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
#[cfg(test)]
mod tests {
use crate::{
SLACalculatorContract, SLACalculatorContractClient, RESULT_SCHEMA_FIELD_COUNT, RESULT_SCHEMA_VERSION,
SLACalculatorContract, SLACalculatorContractClient, CONFIG_SNAPSHOT_VERSION, RESULT_SCHEMA_FIELD_COUNT,
RESULT_SCHEMA_VERSION,
};
use soroban_sdk::{testutils::Address as _, Env, Symbol};

Expand Down Expand Up @@ -216,4 +217,38 @@ mod tests {
panic!("get_config_bundle returned None after initialization");
}
}

// -----------------------------------------------------------------------
// SLAConfigSnapshot version sentinel
// -----------------------------------------------------------------------

#[test]
fn test_config_snapshot_version_sentinel() {
use crate::SLAConfigSnapshot;
use soroban_sdk::{symbol_short, Vec};

let env = Env::default();

// Destructure SLAConfigSnapshot exhaustively so adding or changing fields
// requires updating this sentinel test.
let sample = SLAConfigSnapshot {
version: symbol_short!("v1"),
entries: Vec::new(&env),
};

let SLAConfigSnapshot { version: _, entries: _ } = sample;

let (_env, client) = setup();
let snapshot = client.get_config_snapshot();
assert_eq!(
snapshot.version, CONFIG_SNAPSHOT_VERSION,
"get_config_snapshot version label does not match CONFIG_SNAPSHOT_VERSION"
);
let custom_snapshot = client.get_custom_config_snapshot();
assert_eq!(
custom_snapshot.version, CONFIG_SNAPSHOT_VERSION,
"get_custom_config_snapshot version label does not match CONFIG_SNAPSHOT_VERSION"
);
}
}

55 changes: 55 additions & 0 deletions apexchainx_calculator/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,27 @@ fn test_initialize_stores_roles() {
assert_eq!(client.get_operator(), actors.operator);
}

#[test]
fn test_initialize_supports_equal_admin_and_operator() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register_contract(None, SLACalculatorContract);
let client = SLACalculatorContractClient::new(&env, &contract_id);
let user = Address::generate(&env);

client.initialize(&user, &user);
assert_eq!(client.get_admin(), user);
assert_eq!(client.get_operator(), user);

// Single address holds admin role (e.g. set_config)
client.set_config(&user, &symbol_short!("high"), &25, &60, &800);
assert_eq!(client.get_config(&symbol_short!("high")).threshold_minutes, 25);

// Single address holds operator role (e.g. calculate_sla)
let result = client.calculate_sla(&user, &symbol_short!("EQ1"), &symbol_short!("high"), &10);
assert_eq!(result.status, symbol_short!("met"));
}

#[test]
#[should_panic]
fn test_double_initialize_fails() {
Expand Down Expand Up @@ -6625,6 +6646,39 @@ fn test_385_exact_replay_does_not_emit_dup_input() {
}
}

#[test]
fn test_calculate_sla_view_agrees_with_calculate_sla_on_duplicates_and_replay() {
let (env, client, actors) = setup();
let outage_id = symbol_short!("VIEW_DUP");
let severity = symbol_short!("high");

// Initial calculation via mutating path
let initial = client.calculate_sla(&actors.operator, &outage_id, &severity, &10u32);

// Advance ledger timestamp to verify view replay returns original recorded_at
env.ledger().set_timestamp(initial.recorded_at + 1000);

// Replay case via view: same inputs under unchanged config_version_hash
let view_replay = client.calculate_sla_view(&outage_id, &severity, &10u32);
assert_eq!(
view_replay, initial,
"calculate_sla_view replay must return stored result matching calculate_sla"
);

// Conflict case via view: conflicting mttr under unchanged config_version_hash
let conflict_res = client.try_calculate_sla_view(&outage_id, &severity, &20u32);
assert!(
conflict_res.is_err(),
"calculate_sla_view must reject conflicting duplicate input"
);
let conflict_err = conflict_res.unwrap_err().unwrap();
assert!(error_responses::is_duplicate_outage_input(&conflict_err));

// Ensure view call did not mutate history or stats
assert_eq!(client.get_history().len(), 1);
assert_eq!(client.get_stats().total_calculations, 1);
}

#[test]
fn test_config_bumped_duplicate_treated_as_fresh_calculation() {
// After set_config changes the config_version_hash, a duplicate outage_id
Expand Down Expand Up @@ -8086,6 +8140,7 @@ fn test_240_all_contracttype_structures_round_trip_serialization() {
deprecated_symbols,
severity_aliases,
},
config_version_hash: 123456789,
};
let scval_bundle: soroban_sdk::Val = config_bundle.clone().try_into_val(&env).unwrap();
let restored_bundle: ConfigBundle = scval_bundle.try_into_val(&env).unwrap();
Expand Down
Loading
Loading