Skip to content
Open
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@
- `get_version_info` — version negotiation snapshot for backend startup handshake (SC-W5-029)
- Event Correlation IDs — cross-contract tracing via deterministic correlation IDs generated by `generate_correlation_id` from ledger sequence and formatted with `correlation_event_topics` (SC-W5-079)
- Settlement Intent Event (`set_int`) — Published on every `calculate_sla` call alongside `sla_calc` event for backend reconciliation. It uses topics `(set_int, v1, severity)` and payload `(outage_id: Symbol, status: Symbol, payment_type: Symbol, amount: i128, config_version_hash: u64, recorded_at: u64)` (SC-W5-041)
- `get_pending_admin_state` — Power the admin dashboard pending-proposals view with a single read. Returns `Option<AdminProposalState> { address, proposed_at_ledger, proposed_by }`; `propose_admin` stamps the metadata atomically with `PENDING_ADMIN_KEY`, and `accept_admin` / `cancel_admin_proposal` / `renounce_admin` clear it in lockstep so the two getters always agree. **Migration note**: pending proposals that were stamped on the previous contract version (i.e. before this storage key existed) remain observable from `get_pending_admin` (which still reads `PENDING_ADMIN_KEY`) but `get_pending_admin_state` will return `None` for them; they will be reconciled the next time the proposal is consumed (accepted / cancelled / overwritten). Dashboard consumers should treat the two getters as eventually consistent across an upgrade boundary (#97)
- `get_pending_operator_state` — Mirror of `get_pending_admin_state` for the two-step operator handoff. Returns `Option<OperatorProposalState> { address, proposed_at_ledger, proposed_by }`, written together with `PENDING_OP_KEY` by `propose_operator` and cleared by `accept_operator` / `cancel_operator_proposal` so the legacy and structured getters stay consistent (#97)
- `set_config` reward-margin enforcement — Every severity update (canonical and custom) is rejected when `penalty_per_minute * 1.5 ≥ reward_base` (the integer form `penalty * 3 ≥ reward * 2`), surfaced as new `SLAError::RewardMarginTooLow` (#19) (#92)
- `set_config` cross-severity enforcement — Every canonical severity update is rejected when the proposed write would invert the `penalty(critical) ≥ penalty(high)` order, compared against the live on-chain config and surfaced as new `SLAError::SeverityOrderInvalid` (#20) (#92)
- `validate_general_bounds` / `validate_config` thread `&Env` so the cross-severity rule reads the exact on-chain `critical` / `high` configs at validate time (#92)
- Failure codes 19 (`RewardMarginTooLow`) and 20 (`SeverityOrderInvalid`) appended to `get_failure_schema()` along with the existing 1–18 catalogue (#92)

### Changed
- Soroban SDK reference reconciled to 21.1.0 across `README.md` (badge + tech-stack table), `RUST_TOOLCHAIN_PINNING.md`, `docs/PROJECT_CONTEXT.md`, the contract crate top-of-file doc comment, and `apexchainx_calculator/Cargo.toml`; a triple-grep for `21.1.0` is now consistent across all four documentation surfaces (#73)
- `validate_config` now takes `env: &Env` as the first parameter so it can read live storage for the cross-severity check; the parallel `pub fn` in `src/config.rs` mirrors the new signature (#92)
- `pause` now requires a `reason: String` parameter, records pause metadata (reason, timestamp, initiator), and emits an event payload with the paused status (breaking)

### Changed
- `pause` now requires a `reason: String` parameter, records pause metadata (reason, timestamp, initiator), and emits an event payload with the paused status (breaking)
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<img src="https://img.shields.io/badge/status-active-success.svg" alt="Status: Active">
<img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT">
<img src="https://img.shields.io/badge/version-0.1.0-blueviolet" alt="Version: 0.1.0">
<img src="https://img.shields.io/badge/Soroban_SDK-21.0.0-important" alt="Soroban SDK: 21.0.0">
<img src="https://img.shields.io/badge/Soroban_SDK-21.1.0-important" alt="Soroban SDK: 21.1.0">
<img src="https://img.shields.io/badge/rustc-stable-success" alt="Rust: stable">
<img src="https://img.shields.io/badge/platform-Stellar_Network-000" alt="Platform: Stellar Network">
<a href="https://codecov.io/gh/ApexChainx/ApexChainx-Contracts"><img src="https://codecov.io/gh/ApexChainx/ApexChainx-Contracts/branch/main/graph/badge.svg" alt="Coverage"></a>
Expand Down Expand Up @@ -89,7 +89,7 @@ This contract is responsible for deterministic SLA calculation and related contr
| Layer | Technology | Version |
|-------|-----------|---------|
| Language | Rust | stable (edition 2021) |
| Framework | Soroban SDK | 21.0.0 |
| Framework | Soroban SDK | 21.1.0 |
| Blockchain | Stellar Network | — |
| Build System | Cargo + wasm32 target | — |
| Testing | Soroban testutils + cargo test | — |
Expand Down
2 changes: 1 addition & 1 deletion RUST_TOOLCHAIN_PINNING.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ uses: dtolnay/rust-toolchain@1.94.1
## Why Rust 1.94.1?

1. **Current Stable**: Latest stable release as of implementation (March 2026)
2. **Soroban SDK Compatibility**: Compatible with soroban-sdk v21.0.0
2. **Soroban SDK Compatibility**: Compatible with soroban-sdk v21.1.0
3. **Tested & Verified**: Already in use on development systems
4. **Recent Features**: Includes modern Rust features and optimizations

Expand Down
29 changes: 6 additions & 23 deletions apexchainx_calculator/src/calculation.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
use soroban_sdk::{symbol_short, Address, Env, Symbol, Vec};

use crate::{
SLAConfig, SLAError, SLAResult, SLAStats, SeverityTelemetry,
STATS_KEY, HISTORY_KEY, RETENTION_LIMIT_KEY,
SEVERITY_CALC_COUNTS_KEY, SEVERITY_VIOL_COUNTS_KEY,
LAST_CALCULATION_LEDGER_KEY, LAST_VIOLATION_LEDGER_KEY,
PAUSED_KEY, MAX_HISTORY_SIZE,
EVENT_SLA_CALC, EVENT_SETTLE_INTENT, EVENT_VERSION, EVENT_STATS_SAT,
SLAConfig, SLAError, SLAResult, SLAStats, SeverityTelemetry, EVENT_SETTLE_INTENT, EVENT_SLA_CALC,
EVENT_VERSION, HISTORY_KEY, LAST_CALCULATION_LEDGER_KEY, LAST_VIOLATION_LEDGER_KEY, MAX_HISTORY_SIZE,
PAUSED_KEY, RETENTION_LIMIT_KEY, SEVERITY_CALC_COUNTS_KEY, SEVERITY_VIOL_COUNTS_KEY, STATS_KEY,
};

pub fn calculate_sla(
Expand Down Expand Up @@ -250,11 +247,7 @@ pub fn record_severity_telemetry(env: &Env, severity: &Symbol, met: bool) {
count_lane(calculations, index).saturating_add(1),
);
if !met {
violations = set_count_lane(
violations,
index,
count_lane(violations, index).saturating_add(1),
);
violations = set_count_lane(violations, index, count_lane(violations, index).saturating_add(1));
}

let current_ledger = if now > u64::from(u32::MAX) {
Expand Down Expand Up @@ -292,12 +285,7 @@ pub fn increment_stats(env: &Env, met: bool, reward: i128, penalty: i128) {
match stats.total_calculations.checked_add(1) {
Some(v) => stats.total_calculations = v,
None => {
emit_stats_saturated(
env,
symbol_short!("totcalc"),
stats.total_calculations as i128,
1,
);
emit_stats_saturated(env, symbol_short!("totcalc"), stats.total_calculations as i128, 1);
stats.total_calculations = u64::MAX;
}
}
Expand All @@ -314,12 +302,7 @@ pub fn increment_stats(env: &Env, met: bool, reward: i128, penalty: i128) {
match stats.total_violations.checked_add(1) {
Some(v) => stats.total_violations = v,
None => {
emit_stats_saturated(
env,
symbol_short!("totviol"),
stats.total_violations as i128,
1,
);
emit_stats_saturated(env, symbol_short!("totviol"), stats.total_violations as i128, 1);
stats.total_violations = u64::MAX;
}
}
Expand Down
27 changes: 21 additions & 6 deletions apexchainx_calculator/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
use soroban_sdk::{symbol_short, Env, Map, Symbol, Vec};

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

pub fn set_config(
Expand All @@ -17,7 +15,20 @@ pub fn set_config(
crate::SLACalculatorContract::check_version(env)?;
require_not_frozen(env)?;

crate::SLACalculatorContract::validate_config(&severity, threshold_minutes, penalty_per_minute, reward_base)?;
// Per-field (pure) validation. The cross-severity hierarchy check is a
// separate env-reading call below so `validate_config` stays callable
// from the fuzz targets without an initialised contract environment.
crate::SLACalculatorContract::validate_config(
&severity,
threshold_minutes,
penalty_per_minute,
reward_base,
)?;
crate::SLACalculatorContract::enforce_cross_severity_order(
env,
&severity,
penalty_per_minute,
)?;

let mut configs: Map<Symbol, SLAConfig> = env
.storage()
Expand Down Expand Up @@ -97,7 +108,11 @@ pub fn set_custom_severity(
return Err(SLAError::InvalidSeverity);
}

crate::SLACalculatorContract::validate_general_bounds(threshold_minutes, penalty_per_minute, reward_base)?;
crate::SLACalculatorContract::validate_general_bounds(
threshold_minutes,
penalty_per_minute,
reward_base,
)?;

let mut custom: Map<Symbol, SLAConfig> = env
.storage()
Expand Down
80 changes: 62 additions & 18 deletions apexchainx_calculator/src/governance.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
use soroban_sdk::{Address, Env};

use crate::{
SLAError, ADMIN_KEY, PENDING_ADMIN_KEY, PENDING_OP_KEY, OPERATOR_KEY, EVENT_VERSION,
EVENT_ADMIN_PROP, EVENT_ADMIN_ACC, EVENT_ADMIN_CAN, EVENT_ADMIN_REN, EVENT_OP_PROP,
EVENT_OP_ACC, EVENT_OP_CAN, EVENT_OP_SET,
AdminProposalState, OperatorProposalState, SLAError, ADMIN_KEY, EVENT_ADMIN_ACC, EVENT_ADMIN_CAN,
EVENT_ADMIN_PROP, EVENT_ADMIN_REN, EVENT_OP_ACC, EVENT_OP_CAN, EVENT_OP_PROP, EVENT_OP_SET, EVENT_VERSION,
OPERATOR_KEY, PENDING_ADMIN_KEY, PENDING_ADMIN_STATE_KEY, PENDING_OP_KEY, PENDING_OP_STATE_KEY,
};

pub fn propose_admin(
env: &Env,
caller: &Address,
new_admin: &Address,
) -> Result<(), SLAError> {
pub fn propose_admin(env: &Env, caller: &Address, new_admin: &Address) -> Result<(), SLAError> {
crate::SLACalculatorContract::check_version(env)?;
crate::SLACalculatorContract::require_admin(env, caller)?;
// #97 – keep PENDING_ADMIN_KEY populated for backward compatibility
// with the existing `get_pending_admin` reader.
env.storage().instance().set(&PENDING_ADMIN_KEY, new_admin);
// #97 – additionally stamp the full proposal state so dashboards can
// show who proposed, when (ledger sequence), and to whom, in one read.
env.storage().instance().set(
&PENDING_ADMIN_STATE_KEY,
&AdminProposalState {
address: new_admin.clone(),
proposed_at_ledger: env.ledger().sequence(),
proposed_by: caller.clone(),
},
);
env.events().publish(
(EVENT_ADMIN_PROP, EVENT_VERSION, caller.clone()),
(new_admin.clone(),),
Expand All @@ -34,6 +42,9 @@ pub fn accept_admin(env: &Env, caller: &Address) -> Result<(), SLAError> {
}
env.storage().instance().set(&ADMIN_KEY, caller);
env.storage().instance().remove(&PENDING_ADMIN_KEY);
// #97 – clear the in-progress proposal state so the new reader mirrors
// `get_pending_admin` becoming None.
env.storage().instance().remove(&PENDING_ADMIN_STATE_KEY);
env.events()
.publish((EVENT_ADMIN_ACC, EVENT_VERSION, caller.clone()), ());
Ok(())
Expand All @@ -46,6 +57,8 @@ pub fn cancel_admin_proposal(env: &Env, caller: &Address) -> Result<(), SLAError
return Err(SLAError::NoPendingTransfer);
}
env.storage().instance().remove(&PENDING_ADMIN_KEY);
// #97 – mirror `remove(PENDING_ADMIN_KEY)` so public reads agree.
env.storage().instance().remove(&PENDING_ADMIN_STATE_KEY);
env.events()
.publish((EVENT_ADMIN_CAN, EVENT_VERSION, caller.clone()), ());
Ok(())
Expand All @@ -56,14 +69,29 @@ pub fn get_pending_admin(env: &Env) -> Result<Option<Address>, SLAError> {
Ok(env.storage().instance().get(&PENDING_ADMIN_KEY))
}

pub fn propose_operator(
env: &Env,
caller: &Address,
new_operator: &Address,
) -> Result<(), SLAError> {
/// #97 – Returns the full pending admin proposal state authored by
/// `propose_admin`, or None when no proposal is in flight.
pub fn get_pending_admin_state(env: &Env) -> Result<Option<AdminProposalState>, SLAError> {
crate::SLACalculatorContract::check_version(env)?;
Ok(env.storage().instance().get(&PENDING_ADMIN_STATE_KEY))
}

pub fn propose_operator(env: &Env, caller: &Address, new_operator: &Address) -> Result<(), SLAError> {
crate::SLACalculatorContract::check_version(env)?;
crate::SLACalculatorContract::require_admin(env, caller)?;
// #97 – keep PENDING_OP_KEY populated for backward compatibility with
// the existing `get_pending_operator` reader.
env.storage().instance().set(&PENDING_OP_KEY, new_operator);
// #97 – additionally stamp the full proposal state so dashboards can
// show who proposed, when (ledger sequence), and to whom, in one read.
env.storage().instance().set(
&PENDING_OP_STATE_KEY,
&OperatorProposalState {
address: new_operator.clone(),
proposed_at_ledger: env.ledger().sequence(),
proposed_by: caller.clone(),
},
);
env.events().publish(
(EVENT_OP_PROP, EVENT_VERSION, caller.clone()),
(new_operator.clone(),),
Expand All @@ -84,6 +112,9 @@ pub fn accept_operator(env: &Env, caller: &Address) -> Result<(), SLAError> {
}
env.storage().instance().set(&OPERATOR_KEY, caller);
env.storage().instance().remove(&PENDING_OP_KEY);
// #97 – clear the in-progress proposal state so the new reader mirrors
// `get_pending_operator` becoming None.
env.storage().instance().remove(&PENDING_OP_STATE_KEY);
env.events()
.publish((EVENT_OP_ACC, EVENT_VERSION, caller.clone()), ());
Ok(())
Expand All @@ -96,6 +127,9 @@ pub fn cancel_operator_proposal(env: &Env, caller: &Address) -> Result<(), SLAEr
return Err(SLAError::NoPendingTransfer);
}
env.storage().instance().remove(&PENDING_OP_KEY);
// #97 – mirror `remove(PENDING_OP_KEY)` so the new reader is consistent
// with the legacy `get_pending_operator` reader.
env.storage().instance().remove(&PENDING_OP_STATE_KEY);
env.events()
.publish((EVENT_OP_CAN, EVENT_VERSION, caller.clone()), ());
Ok(())
Expand All @@ -106,21 +140,31 @@ pub fn get_pending_operator(env: &Env) -> Result<Option<Address>, SLAError> {
Ok(env.storage().instance().get(&PENDING_OP_KEY))
}

/// #97 – Returns the full pending operator proposal state authored by
/// `propose_operator`, or None when no proposal is in flight. Mirrors the
/// admin counterpart so dashboards can render operator proposals from a
/// single read.
pub fn get_pending_operator_state(
env: &Env,
) -> Result<Option<OperatorProposalState>, SLAError> {
crate::SLACalculatorContract::check_version(env)?;
Ok(env.storage().instance().get(&PENDING_OP_STATE_KEY))
}

pub fn renounce_admin(env: &Env, caller: &Address) -> Result<(), SLAError> {
crate::SLACalculatorContract::check_version(env)?;
crate::SLACalculatorContract::require_admin(env, caller)?;
env.storage().instance().remove(&ADMIN_KEY);
env.storage().instance().remove(&PENDING_ADMIN_KEY);
// #97 – ensure the full proposal state is also cleared so
// `get_pending_admin_state` returns None after renouncement.
env.storage().instance().remove(&PENDING_ADMIN_STATE_KEY);
env.events()
.publish((EVENT_ADMIN_REN, EVENT_VERSION, caller.clone()), ());
Ok(())
}

pub fn set_operator(
env: &Env,
caller: &Address,
new_operator: &Address,
) -> Result<(), SLAError> {
pub fn set_operator(env: &Env, caller: &Address, new_operator: &Address) -> Result<(), SLAError> {
crate::SLACalculatorContract::check_version(env)?;
crate::SLACalculatorContract::require_admin(env, caller)?;
env.storage().instance().set(&OPERATOR_KEY, new_operator);
Expand Down
10 changes: 6 additions & 4 deletions apexchainx_calculator/src/history.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use soroban_sdk::{Address, Env, Symbol, Vec};

use crate::{
SLAError, SLAResult,
HISTORY_KEY, RETENTION_LIMIT_KEY, MAX_HISTORY_SIZE, EVENT_VERSION, EVENT_PRUNED, EVENT_PRUNED_AGE,
SLAError, SLAResult, EVENT_PRUNED, EVENT_PRUNED_AGE, EVENT_VERSION, HISTORY_KEY, MAX_HISTORY_SIZE,
RETENTION_LIMIT_KEY,
};

pub fn get_history(env: &Env) -> Result<Vec<SLAResult>, SLAError> {
Expand Down Expand Up @@ -34,8 +34,10 @@ pub fn prune_history(env: &Env, caller: &Address, keep_latest: u32) -> Result<()
}

env.storage().instance().set(&HISTORY_KEY, &new_history);
env.events()
.publish((EVENT_PRUNED, EVENT_VERSION, caller.clone()), (remove_count, keep_latest));
env.events().publish(
(EVENT_PRUNED, EVENT_VERSION, caller.clone()),
(remove_count, keep_latest),
);
}

Ok(())
Expand Down
Loading
Loading