Skip to content
Merged
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
Jump to file
Failed to load files.
Loading
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
23 changes: 23 additions & 0 deletions apexchainx_calculator/src/calculation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,29 @@ 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;
for i in 0..history.len() {
let entry = history.get(i).unwrap();
if entry.outage_id == outage_id {
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);
}
}

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 @@ -8,7 +8,7 @@ use soroban_sdk::{symbol_short, Env, Map, Symbol, Vec};

use crate::{
config_freeze, config_metadata, SLAConfig, SLAConfigEntry, SLAConfigSnapshot, SLAError, CONFIG_KEY,
CUSTOM_CONFIG_KEY, EVENT_CONFIG_UPD, EVENT_VERSION,
CONFIG_SNAPSHOT_SCHEMA_VERSION, CUSTOM_CONFIG_KEY, EVENT_CONFIG_UPD, EVENT_VERSION,
};

/// Sets the SLA configuration for a given severity level.
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: CONFIG_SNAPSHOT_SCHEMA_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: CONFIG_SNAPSHOT_SCHEMA_VERSION,
entries,
})
}
Expand Down
14 changes: 14 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 @@ -73,6 +75,12 @@ mod tests {
bundle.is_some(),
"ConfigBundle must be available after initialize()",
);
let b = bundle.unwrap();
assert_eq!(
b.config_version_hash,
client.get_config_version_hash(),
"ConfigBundle config_version_hash must match get_config_version_hash()",
);
}

#[test]
Expand Down Expand Up @@ -126,6 +134,11 @@ mod tests {
assert_eq!(entry.config.threshold_minutes, 42);
assert_eq!(entry.config.penalty_per_minute, 111);
assert_eq!(entry.config.reward_base, 999);
assert_eq!(
bundle.config_version_hash,
client.get_config_version_hash(),
"Bundle config_version_hash must update when config changes",
);
}

#[test]
Expand All @@ -144,6 +157,7 @@ mod tests {

assert_eq!(a.snapshot, b.snapshot);
assert_eq!(a.schema, b.schema);
assert_eq!(a.config_version_hash, b.config_version_hash);
assert_eq!(a.snapshot.entries.len(), 4);
assert_eq!(a.schema.status_met, symbol_short!("met"));
}
Expand Down
59 changes: 50 additions & 9 deletions apexchainx_calculator/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,19 @@ pub(crate) const RESULT_SCHEMA_VERSION: u32 = 1;
/// 6. See `docs/result-schema-migration-guard.md` for the full process.
pub(crate) const RESULT_SCHEMA_FIELD_COUNT: u32 = 9;

/// Version label of the SLAConfigSnapshot schema exposed via get_config_snapshot().
/// Incremented/bumped when snapshot layout changes in a breaking way.
pub(crate) const CONFIG_SNAPSHOT_SCHEMA_VERSION: Symbol = symbol_short!("v1");

/// Number of named fields in `SLAConfigSnapshot`.
///
/// This constant is the migration guardrail for `SLAConfigSnapshot`.
/// It must be updated in the same commit that adds or removes a field from
/// `SLAConfigSnapshot`. The companion test `test_config_snapshot_schema_field_count_sentinel`
/// in `schema_migration_tests.rs` will fail CI if the struct layout changes
/// without a corresponding update to this constant and `CONFIG_SNAPSHOT_SCHEMA_VERSION`.
pub(crate) const CONFIG_SNAPSHOT_SCHEMA_FIELD_COUNT: u32 = 2;

/// Hard upper bound on retained history entries. (SC-062)
/// Configurable down to 1 via set_retention_limit().
pub(crate) const MAX_HISTORY_SIZE: u32 = 1000;
Expand Down Expand Up @@ -1061,6 +1074,11 @@ impl SLACalculatorContract {
/// Deploy the contract.
/// `admin` – may update config, pause/unpause, and assign the operator.
/// `operator` – may call `calculate_sla`.
///
/// # Role Distinctness & Single-Address Mode
/// Both `admin` and `operator` signatures are required at initialization. However, `admin` and
/// `operator` may be set to the same address for single-key / merged-role deployments where
/// role separation is not required. In this case, both authorization checks are satisfied by a single signature.
pub fn initialize(env: Env, admin: Address, operator: Address) -> Result<(), SLAError> {
if env.storage().instance().has(&ADMIN_KEY) {
return Err(SLAError::AlreadyInitialized);
Expand Down Expand Up @@ -1656,7 +1674,7 @@ impl SLACalculatorContract {
}

Ok(SLAConfigSnapshot {
version: symbol_short!("v1"),
version: CONFIG_SNAPSHOT_SCHEMA_VERSION,
entries,
})
}
Expand Down Expand Up @@ -1700,7 +1718,7 @@ impl SLACalculatorContract {
}

Ok(SLAConfigSnapshot {
version: symbol_short!("v1"),
version: CONFIG_SNAPSHOT_SCHEMA_VERSION,
entries,
})
}
Expand Down Expand Up @@ -1742,7 +1760,7 @@ impl SLACalculatorContract {
entries.push_back(SLAConfigEntry { severity, config });
}
Ok(SLAConfigSnapshot {
version: symbol_short!("v1"),
version: CONFIG_SNAPSHOT_SCHEMA_VERSION,
entries,
})
}
Expand Down Expand Up @@ -1850,8 +1868,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 @@ -2224,11 +2247,29 @@ impl SLACalculatorContract {
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.
// Apply duplicate/replay policy read-only against recorded history
let history: Vec<SLAResult> = env
.storage()
.instance()
.get(&HISTORY_KEY)
.unwrap_or_else(|| Vec::new(&env));

let mut existing: Option<SLAResult> = None;
for i in 0..history.len() {
let entry = history.get(i).unwrap();
if entry.outage_id == outage_id {
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);
}
}

// 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,
Expand Down
31 changes: 30 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_SCHEMA_FIELD_COUNT,
CONFIG_SNAPSHOT_SCHEMA_VERSION, RESULT_SCHEMA_FIELD_COUNT, RESULT_SCHEMA_VERSION,
};
use soroban_sdk::{testutils::Address as _, Env, Symbol};

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

// -----------------------------------------------------------------------
// SLAConfigSnapshot sentinel: field count must match CONFIG_SNAPSHOT_SCHEMA_FIELD_COUNT
// -----------------------------------------------------------------------

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

let env = Env::default();
let sample = SLAConfigSnapshot {
version: CONFIG_SNAPSHOT_SCHEMA_VERSION,
entries: Vec::new(&env),
};

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

assert_eq!(
CONFIG_SNAPSHOT_SCHEMA_FIELD_COUNT, 2,
"CONFIG_SNAPSHOT_SCHEMA_FIELD_COUNT is out of sync with SLAConfigSnapshot. \
Update lib.rs::CONFIG_SNAPSHOT_SCHEMA_FIELD_COUNT and \
CONFIG_SNAPSHOT_SCHEMA_VERSION when adding or removing fields."
);
}
}
67 changes: 67 additions & 0 deletions apexchainx_calculator/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,32 @@ fn test_initialize_stores_roles() {
assert_eq!(client.get_operator(), actors.operator);
}

#[test]
fn test_initialize_single_address_merged_roles() {
let env = Env::default();
env.mock_all_auths();
let cid = env.register_contract(None, SLACalculatorContract);
let client = SLACalculatorContractClient::new(&env, &cid);
let single_key = soroban_sdk::Address::generate(&env);

client.initialize(&single_key, &single_key);

assert_eq!(client.get_admin(), single_key);
assert_eq!(client.get_operator(), single_key);

// Single key can invoke both admin and operator methods
let result = client.calculate_sla(
&single_key,
&symbol_short!("INC001"),
&symbol_short!("critical"),
&10,
);
assert_eq!(result.status, symbol_short!("met"));

client.set_config(&single_key, &symbol_short!("critical"), &20, &200, &1000);
assert_eq!(client.get_config(&symbol_short!("critical")).threshold_minutes, 20);
}

#[test]
#[should_panic]
fn test_double_initialize_fails() {
Expand Down Expand Up @@ -5210,6 +5236,46 @@ fn test_invariance_critical_all_rating_zones() {
}
}

#[test]
fn test_calculate_sla_view_detects_duplicate_conflict() {
let (_env, client, actors) = setup();
let outage_id = symbol_short!("OUTDUP1");
let severity = symbol_short!("critical");

// First, record a calculation via mutating path
let res1 = client.calculate_sla(&actors.operator, &outage_id, &severity, &10);
assert_eq!(res1.status, symbol_short!("met"));

// View call with conflicting mttr must return DuplicateOutageInput error
let res2 = client.try_calculate_sla_view(&outage_id, &severity, &20);
assert_eq!(res2, Err(Ok(SLAError::DuplicateOutageInput)));
}

#[test]
fn test_calculate_sla_view_handles_replay() {
let (env, client, actors) = setup();
env.ledger().set_timestamp(1000);
let outage_id = symbol_short!("OUTREP1");
let severity = symbol_short!("critical");

// Record calculation at t = 1000
let orig = client.calculate_sla(&actors.operator, &outage_id, &severity, &10);
assert_eq!(orig.recorded_at, 1000);

// Advance time to t = 2000
env.ledger().set_timestamp(2000);

// View call for identical outage_id and mttr must replay stored result with original timestamp (1000)
let replayed = client.calculate_sla_view(&outage_id, &severity, &10);
assert_eq!(replayed.recorded_at, 1000);
assert_eq!(replayed.amount, orig.amount);
assert_eq!(replayed.status, orig.status);

// Assert side-effect free: history count remains 1
let history = client.get_history_page(&0, &10);
assert_eq!(history.entries.len(), 1);
}

#[test]
fn test_invariance_high_all_rating_zones() {
let (_env, client, actors) = setup();
Expand Down Expand Up @@ -8086,6 +8152,7 @@ fn test_240_all_contracttype_structures_round_trip_serialization() {
deprecated_symbols,
severity_aliases,
},
config_version_hash: 12345,
};
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