diff --git a/.gitignore b/.gitignore
index c523263..6234d68 100644
--- a/.gitignore
+++ b/.gitignore
@@ -132,3 +132,19 @@ cargo-audit.toml
__pycache__/
*.pyc
.pytest_cache/
+
+# ============================================================
+# Generated JSON / XDR artefacts (local dev only)
+# ============================================================
+*.xdr
+*.base64
+generated/
+.soroban/
+soroban_derived.json
+
+# ============================================================
+# Editor session / workspace state
+# ============================================================
+*.code-workspace
+.history/
+.direnv/
diff --git a/contracts/split/src/audit_logging.rs b/contracts/split/src/audit_logging.rs
new file mode 100644
index 0000000..55f35c4
--- /dev/null
+++ b/contracts/split/src/audit_logging.rs
@@ -0,0 +1,426 @@
+//! Issue #1009: Comprehensive audit logging for regulatory compliance.
+//!
+//! Provides a contract-wide, access-controlled audit trail that captures
+//! every significant administrative or state-changing operation:
+//!
+//! - **Event logging** — a bounded, append-only log of `ComplianceLogEntry`
+//! records keyed by a monotonic sequence number.
+//! - **Data retention** — configurable retention window; entries older than
+//! the window can be purged by an authorised admin (with an event emitted).
+//! - **Encryption metadata** — each entry carries an optional `data_hash`
+//! (SHA-256 of the off-chain encrypted payload) so the log is tamper-evident
+//! without storing plaintext PII on-chain.
+//! - **Access controls** — a dedicated `AuditReader` role (distinct from
+//! Operator/SuperAdmin) gates log reads; writes are always admin-initiated.
+//! - **Analysis helpers** — `count_by_kind`, `entries_in_range`, and
+//! `get_summary_stats` provide on-chain queryable analytics.
+//!
+//! ## Storage layout
+//!
+//! | Key symbol | Tier | Type | Purpose |
+//! |----------------|------------|----------------------------|-----------------------------|
+//! | `al_seq` | instance | `u64` | Global log sequence counter |
+//! | `al_entry(n)` | persistent | `ComplianceLogEntry` | Log entry n |
+//! | `al_head` | instance | `u64` | Oldest retained entry seq |
+//! | `al_retntn` | instance | `u64` | Retention window in seconds |
+//! | `al_readers` | persistent | `Vec
` | Authorised audit reader list|
+//! | `al_stats` | instance | `AuditStats` | Aggregate counts by kind |
+
+use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, Map, String, Symbol, Vec};
+
+// ---------------------------------------------------------------------------
+// Limits
+// ---------------------------------------------------------------------------
+
+/// Hard cap on the number of log entries kept on-chain at any time.
+/// Oldest entries are dropped when the cap is exceeded.
+pub const MAX_LOG_ENTRIES: u64 = 500;
+
+/// Maximum number of authorised audit readers.
+pub const MAX_READERS: u32 = 30;
+
+/// Default retention window: 90 days in seconds.
+pub const DEFAULT_RETENTION_SECS: u64 = 90 * 24 * 60 * 60;
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+/// Category of an auditable event.
+#[contracttype]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum LogEventKind {
+ /// An administrative action (pause, config change, role grant…).
+ AdminAction = 0,
+ /// An invoice lifecycle transition (created, released, refunded…).
+ InvoiceLifecycle = 1,
+ /// A payment received or transferred.
+ Payment = 2,
+ /// A security event (circuit breaker, failed auth, anomaly).
+ SecurityEvent = 3,
+ /// A contract upgrade (proposed, executed, rolled back).
+ UpgradeEvent = 4,
+ /// A rate-limiting event (limit hit, backoff applied).
+ RateLimitEvent = 5,
+ /// Any other event not covered above.
+ Other = 6,
+}
+
+/// A single compliance log entry.
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ComplianceLogEntry {
+ /// Monotonically increasing sequence number across all entries.
+ pub seq: u64,
+ /// Category of the event.
+ pub kind: LogEventKind,
+ /// Short action tag (e.g. `pause`, `pay`, `upg_exec`).
+ pub action: Symbol,
+ /// Address that initiated the action.
+ pub actor: Address,
+ /// Invoice ID if the event is scoped to an invoice (0 = contract-level).
+ pub invoice_id: u64,
+ /// Unix timestamp of the event.
+ pub timestamp: u64,
+ /// Soroban ledger sequence of the event.
+ pub ledger: u32,
+ /// SHA-256 hash of the off-chain encrypted detail payload (optional).
+ /// Allows off-chain systems to verify the log is tamper-evident.
+ pub data_hash: Option>,
+ /// Free-form note (on-chain, kept short for gas efficiency).
+ pub note: String,
+}
+
+/// Aggregate statistics for the audit log.
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AuditStats {
+ /// Total entries ever appended (never decremented, survives purges).
+ pub total_appended: u64,
+ /// Total entries purged via retention sweeps.
+ pub total_purged: u64,
+ /// Count of entries per `LogEventKind` discriminant (0–6).
+ pub counts_by_kind: Map,
+}
+
+// ---------------------------------------------------------------------------
+// Storage keys
+// ---------------------------------------------------------------------------
+
+fn seq_key() -> Symbol {
+ symbol_short!("al_seq")
+}
+
+fn head_key() -> Symbol {
+ symbol_short!("al_head")
+}
+
+fn retention_key() -> Symbol {
+ symbol_short!("al_retntn")
+}
+
+fn readers_key() -> Symbol {
+ symbol_short!("al_readrs")
+}
+
+fn stats_key() -> Symbol {
+ symbol_short!("al_stats")
+}
+
+/// Key for a specific log entry (persistent storage).
+fn entry_key(seq: u64) -> (Symbol, u64) {
+ (symbol_short!("al_entry"), seq)
+}
+
+// ---------------------------------------------------------------------------
+// Accessors
+// ---------------------------------------------------------------------------
+
+fn get_seq(env: &Env) -> u64 {
+ env.storage().instance().get(&seq_key()).unwrap_or(0u64)
+}
+
+fn increment_seq(env: &Env) -> u64 {
+ let next = get_seq(env) + 1;
+ env.storage().instance().set(&seq_key(), &next);
+ next
+}
+
+fn get_head(env: &Env) -> u64 {
+ env.storage().instance().get(&head_key()).unwrap_or(1u64)
+}
+
+fn set_head(env: &Env, head: u64) {
+ env.storage().instance().set(&head_key(), &head);
+}
+
+pub fn get_retention_secs(env: &Env) -> u64 {
+ env.storage()
+ .instance()
+ .get(&retention_key())
+ .unwrap_or(DEFAULT_RETENTION_SECS)
+}
+
+pub fn set_retention_secs(env: &Env, secs: u64) {
+ assert!(secs > 0, "retention window must be positive");
+ env.storage().instance().set(&retention_key(), &secs);
+ emit_retention_configured(env, secs);
+}
+
+pub fn get_stats(env: &Env) -> AuditStats {
+ env.storage()
+ .instance()
+ .get(&stats_key())
+ .unwrap_or_else(|| AuditStats {
+ total_appended: 0,
+ total_purged: 0,
+ counts_by_kind: Map::new(env),
+ })
+}
+
+fn set_stats(env: &Env, stats: &AuditStats) {
+ env.storage().instance().set(&stats_key(), stats);
+}
+
+// ---------------------------------------------------------------------------
+// Access-controlled reader list
+// ---------------------------------------------------------------------------
+
+pub fn get_readers(env: &Env) -> Vec {
+ env.storage()
+ .persistent()
+ .get(&readers_key())
+ .unwrap_or_else(|| Vec::new(env))
+}
+
+fn set_readers(env: &Env, readers: &Vec) {
+ env.storage().persistent().set(&readers_key(), readers);
+}
+
+/// Add an audit reader. Callers must hold SuperAdmin or Operator role (enforced
+/// before calling this helper).
+pub fn add_reader(env: &Env, admin: &Address, reader: Address) {
+ let _ = admin;
+ let mut readers = get_readers(env);
+ if readers.iter().any(|r| r == reader) {
+ return; // idempotent
+ }
+ assert!(readers.len() < MAX_READERS, "reader list full");
+ readers.push_back(reader.clone());
+ set_readers(env, &readers);
+ emit_reader_added(env, admin, &reader);
+}
+
+/// Remove an audit reader.
+pub fn remove_reader(env: &Env, admin: &Address, reader: &Address) {
+ let mut readers = get_readers(env);
+ let mut new_readers = Vec::new(env);
+ let mut found = false;
+ for r in readers.iter() {
+ if &r == reader {
+ found = true;
+ } else {
+ new_readers.push_back(r);
+ }
+ }
+ assert!(found, "reader not found");
+ set_readers(env, &new_readers);
+ emit_reader_removed(env, admin, reader);
+}
+
+/// Returns `true` when `addr` is an authorised audit reader or holds admin rights.
+pub fn is_authorised_reader(env: &Env, addr: &Address) -> bool {
+ get_readers(env).iter().any(|r| &r == addr)
+}
+
+// ---------------------------------------------------------------------------
+// Logging
+// ---------------------------------------------------------------------------
+
+/// Append a compliance log entry. Returns the assigned sequence number.
+///
+/// This is the primary write path; it is safe to call from any admin function.
+/// Overflows are handled gracefully: if the on-chain buffer exceeds
+/// `MAX_LOG_ENTRIES`, the oldest entry is evicted before writing the new one.
+pub fn append(
+ env: &Env,
+ kind: LogEventKind,
+ action: Symbol,
+ actor: &Address,
+ invoice_id: u64,
+ data_hash: Option>,
+ note: String,
+) -> u64 {
+ let seq = increment_seq(env);
+ let entry = ComplianceLogEntry {
+ seq,
+ kind,
+ action: action.clone(),
+ actor: actor.clone(),
+ invoice_id,
+ timestamp: env.ledger().timestamp(),
+ ledger: env.ledger().sequence(),
+ data_hash,
+ note,
+ };
+ env.storage().persistent().set(&entry_key(seq), &entry);
+
+ // Update stats.
+ let mut stats = get_stats(env);
+ stats.total_appended += 1;
+ let kind_disc = kind as u32;
+ let count = stats.counts_by_kind.get(kind_disc).unwrap_or(0u64);
+ stats.counts_by_kind.set(kind_disc, count + 1);
+ set_stats(env, &stats);
+
+ // Evict oldest if over the cap.
+ let head = get_head(env);
+ let total_live = seq.saturating_sub(head) + 1;
+ if total_live > MAX_LOG_ENTRIES {
+ env.storage().persistent().remove(&entry_key(head));
+ set_head(env, head + 1);
+ }
+
+ emit_entry_appended(env, seq, &action, actor, kind);
+ seq
+}
+
+// ---------------------------------------------------------------------------
+// Read API
+// ---------------------------------------------------------------------------
+
+/// Retrieve a single log entry by its sequence number.
+/// Returns `None` if the entry has been evicted or doesn't exist.
+pub fn get_entry(env: &Env, seq: u64) -> Option {
+ env.storage().persistent().get(&entry_key(seq))
+}
+
+/// Current range of live (non-evicted) sequence numbers: `(head, tail)`.
+/// `head` is the oldest live entry; `tail` is the most recently appended.
+pub fn get_live_range(env: &Env) -> (u64, u64) {
+ let tail = get_seq(env);
+ let head = get_head(env);
+ (head, tail)
+}
+
+/// Retrieve up to `limit` entries starting from `from_seq` (inclusive).
+/// Skips evicted entries silently.
+pub fn get_entries_from(env: &Env, from_seq: u64, limit: u32) -> Vec {
+ let tail = get_seq(env);
+ let mut result = Vec::new(env);
+ let mut seq = from_seq;
+ let mut count = 0u32;
+ while seq <= tail && count < limit {
+ if let Some(entry) = get_entry(env, seq) {
+ result.push_back(entry);
+ count += 1;
+ }
+ seq += 1;
+ }
+ result
+}
+
+/// Count live entries by event kind.
+pub fn count_by_kind(env: &Env, kind: LogEventKind) -> u64 {
+ let stats = get_stats(env);
+ stats.counts_by_kind.get(kind as u32).unwrap_or(0u64)
+}
+
+/// Return entries (up to `max_results`) whose `timestamp` falls in
+/// `[from_ts, to_ts]` inclusive. Scans from the head forward.
+pub fn entries_in_range(
+ env: &Env,
+ from_ts: u64,
+ to_ts: u64,
+ max_results: u32,
+) -> Vec {
+ let (head, tail) = get_live_range(env);
+ let mut result = Vec::new(env);
+ let mut seq = head;
+ while seq <= tail && result.len() < max_results {
+ if let Some(entry) = get_entry(env, seq) {
+ if entry.timestamp >= from_ts && entry.timestamp <= to_ts {
+ result.push_back(entry);
+ }
+ }
+ seq += 1;
+ }
+ result
+}
+
+// ---------------------------------------------------------------------------
+// Retention / purge
+// ---------------------------------------------------------------------------
+
+/// Purge entries older than the configured retention window.
+/// Emits a `purged` event with the count of entries removed.
+/// Returns the number of entries purged.
+pub fn purge_expired(env: &Env, admin: &Address) -> u64 {
+ let retention = get_retention_secs(env);
+ let cutoff_ts = env.ledger().timestamp().saturating_sub(retention);
+ let (head, tail) = get_live_range(env);
+ let mut purged = 0u64;
+ let mut new_head = head;
+
+ let mut seq = head;
+ while seq <= tail {
+ match get_entry(env, seq) {
+ Some(entry) if entry.timestamp < cutoff_ts => {
+ env.storage().persistent().remove(&entry_key(seq));
+ purged += 1;
+ new_head = seq + 1;
+ }
+ _ => break, // entries are in timestamp order; stop at first live one
+ }
+ seq += 1;
+ }
+
+ if purged > 0 {
+ set_head(env, new_head);
+ let mut stats = get_stats(env);
+ stats.total_purged += purged;
+ set_stats(env, &stats);
+ emit_entries_purged(env, admin, purged, new_head);
+ }
+
+ purged
+}
+
+// ---------------------------------------------------------------------------
+// Events
+// ---------------------------------------------------------------------------
+
+fn emit_entry_appended(env: &Env, seq: u64, action: &Symbol, actor: &Address, kind: LogEventKind) {
+ env.events().publish(
+ (symbol_short!("al"), symbol_short!("appended")),
+ (seq, action.clone(), actor.clone(), kind as u32),
+ );
+}
+
+fn emit_entries_purged(env: &Env, admin: &Address, count: u64, new_head: u64) {
+ env.events().publish(
+ (symbol_short!("al"), symbol_short!("purged")),
+ (admin.clone(), count, new_head),
+ );
+}
+
+fn emit_retention_configured(env: &Env, secs: u64) {
+ env.events().publish(
+ (symbol_short!("al"), symbol_short!("retntn")),
+ secs,
+ );
+}
+
+fn emit_reader_added(env: &Env, admin: &Address, reader: &Address) {
+ env.events().publish(
+ (symbol_short!("al"), symbol_short!("rdr_add")),
+ (admin.clone(), reader.clone()),
+ );
+}
+
+fn emit_reader_removed(env: &Env, admin: &Address, reader: &Address) {
+ env.events().publish(
+ (symbol_short!("al"), symbol_short!("rdr_del")),
+ (admin.clone(), reader.clone()),
+ );
+}
diff --git a/contracts/split/src/contract_upgrade.rs b/contracts/split/src/contract_upgrade.rs
new file mode 100644
index 0000000..d667a8e
--- /dev/null
+++ b/contracts/split/src/contract_upgrade.rs
@@ -0,0 +1,421 @@
+//! Issue #1010: Enhanced contract upgrade mechanism.
+//!
+//! Extends the basic `propose_upgrade`/`execute_upgrade`/`cancel_upgrade` in
+//! `lib.rs` with:
+//! - Semantic version management (SemVer-style major.minor.patch).
+//! - Per-upgrade migration manifests that describe what changes.
+//! - Rollback capability: the previous WASM hash is retained so the admin
+//! can roll back to the prior version.
+//! - Pre-upgrade validation hooks (storage-layout checks, invariant checks).
+//! - Notification events and a structured upgrade history log.
+//!
+//! # Architecture
+//!
+//! This module records *metadata* about upgrades. The actual WASM swap is
+//! still performed through `lib.rs`'s `execute_upgrade` path, which calls
+//! `env.deployer().update_current_contract_wasm(...)`. This module adds the
+//! bookkeeping layer around that call.
+//!
+//! ## Storage layout
+//!
+//! | Key symbol | Tier | Type | Purpose |
+//! |----------------|------------|----------------------------|----------------------------------|
+//! | `cu_version` | instance | `ContractVersion` | Current semantic version |
+//! | `cu_prev_wasm` | instance | `BytesN<32>` | Previous WASM hash for rollback |
+//! | `cu_prev_ver` | instance | `ContractVersion` | Previous semantic version |
+//! | `cu_manifest` | persistent | `UpgradeManifest` | Pending upgrade manifest |
+//! | `cu_history` | persistent | `Vec` | Bounded upgrade history |
+//! | `cu_rb_cnt` | instance | `u32` | Rollback counter (safeguard) |
+
+use soroban_sdk::{contracttype, symbol_short, BytesN, Env, String, Symbol, Vec};
+
+// ---------------------------------------------------------------------------
+// Limits
+// ---------------------------------------------------------------------------
+
+/// Maximum entries retained in the upgrade history.
+pub const MAX_UPGRADE_HISTORY: u32 = 20;
+
+/// Maximum rollback operations allowed before a manual reset is required.
+pub const MAX_ROLLBACK_COUNT: u32 = 3;
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+/// Semantic version for the contract WASM.
+#[contracttype]
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct ContractVersion {
+ pub major: u32,
+ pub minor: u32,
+ pub patch: u32,
+}
+
+impl ContractVersion {
+ /// Returns `true` when `other` is strictly greater than `self`.
+ pub fn is_upgrade_of(&self, other: &ContractVersion) -> bool {
+ (other.major, other.minor, other.patch) > (self.major, self.minor, self.patch)
+ }
+}
+
+/// Describes what the upgrade changes. Stored alongside the pending proposal.
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct UpgradeManifest {
+ /// New WASM hash being proposed.
+ pub new_wasm_hash: BytesN<32>,
+ /// New semantic version after the upgrade.
+ pub new_version: ContractVersion,
+ /// Summary of breaking changes (empty = backwards-compatible).
+ pub breaking_changes: String,
+ /// Summary of new features.
+ pub new_features: String,
+ /// Whether a storage migration script should run after the WASM swap.
+ pub requires_migration: bool,
+ /// Proposer's address (informational, verified by lib.rs admin check).
+ pub proposed_by_note: String,
+ /// Ledger when the proposal was created.
+ pub proposed_at_ledger: u32,
+ /// Timestamp when the proposal was created.
+ pub proposed_at_ts: u64,
+}
+
+/// Pre-validation outcome for an upgrade.
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct UpgradeValidationResult {
+ pub passed: bool,
+ /// Number of checks that passed.
+ pub checks_passed: u32,
+ /// Number of checks that failed.
+ pub checks_failed: u32,
+ /// Human-readable summary.
+ pub summary: String,
+}
+
+/// One entry in the bounded upgrade history log.
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct UpgradeHistoryEntry {
+ pub from_version: ContractVersion,
+ pub to_version: ContractVersion,
+ pub wasm_hash: BytesN<32>,
+ pub executed_at_ledger: u32,
+ pub executed_at_ts: u64,
+ /// `true` if this entry represents a rollback.
+ pub is_rollback: bool,
+}
+
+// ---------------------------------------------------------------------------
+// Storage keys
+// ---------------------------------------------------------------------------
+
+fn version_key() -> Symbol {
+ symbol_short!("cu_versn")
+}
+
+fn prev_wasm_key() -> Symbol {
+ symbol_short!("cu_prwsm")
+}
+
+fn prev_version_key() -> Symbol {
+ symbol_short!("cu_prver")
+}
+
+fn manifest_key() -> Symbol {
+ symbol_short!("cu_mnfst")
+}
+
+fn history_key() -> Symbol {
+ symbol_short!("cu_hist")
+}
+
+fn rollback_count_key() -> Symbol {
+ symbol_short!("cu_rbcnt")
+}
+
+// ---------------------------------------------------------------------------
+// Version management
+// ---------------------------------------------------------------------------
+
+/// Read the current contract semantic version (default 0.0.0 for fresh deploys).
+pub fn get_version(env: &Env) -> ContractVersion {
+ env.storage()
+ .instance()
+ .get(&version_key())
+ .unwrap_or_default()
+}
+
+/// Stamp the initial version on a freshly deployed contract. Should be called
+/// from `initialize()` so the version record exists from day one.
+pub fn init_version(env: &Env, version: ContractVersion) {
+ env.storage().instance().set(&version_key(), &version);
+}
+
+/// Update the stored version. Called immediately after the WASM swap succeeds.
+fn set_version(env: &Env, version: ContractVersion) {
+ env.storage().instance().set(&version_key(), &version);
+}
+
+/// Read the previous version (the one active before the last upgrade).
+pub fn get_previous_version(env: &Env) -> Option {
+ env.storage().instance().get(&prev_version_key())
+}
+
+/// Read the previous WASM hash (for rollback).
+pub fn get_previous_wasm_hash(env: &Env) -> Option> {
+ env.storage().instance().get(&prev_wasm_key())
+}
+
+// ---------------------------------------------------------------------------
+// Manifest management
+// ---------------------------------------------------------------------------
+
+/// Store an upgrade manifest alongside the pending proposal.
+pub fn propose_manifest(env: &Env, manifest: &UpgradeManifest) {
+ // Version must be strictly greater than current.
+ let current = get_version(env);
+ assert!(
+ current.is_upgrade_of(&manifest.new_version),
+ "new version must be strictly greater than current version"
+ );
+ env.storage().persistent().set(&manifest_key(), manifest);
+ emit_manifest_proposed(env, &manifest.new_wasm_hash, manifest.new_version);
+}
+
+/// Return the pending manifest, if any.
+pub fn get_manifest(env: &Env) -> Option {
+ env.storage().persistent().get(&manifest_key())
+}
+
+/// Remove the pending manifest (e.g. when the proposal is cancelled).
+pub fn clear_manifest(env: &Env) {
+ env.storage().persistent().remove(&manifest_key());
+}
+
+// ---------------------------------------------------------------------------
+// Pre-upgrade validation
+// ---------------------------------------------------------------------------
+
+/// Run pre-upgrade validation checks and return the result.
+///
+/// Checks performed:
+/// 1. A manifest is present and the version is increasing.
+/// 2. The WASM hash in the manifest matches the proposal.
+/// 3. No active Critical pause conditions block the upgrade.
+///
+/// This does **not** block execution — it returns the result for the caller to
+/// decide whether to proceed. Callers may enforce `passed == true` if desired.
+pub fn validate_upgrade(env: &Env, proposed_wasm_hash: &BytesN<32>) -> UpgradeValidationResult {
+ let mut checks_passed = 0u32;
+ let mut checks_failed = 0u32;
+
+ // Check 1: manifest present.
+ let manifest = match get_manifest(env) {
+ Some(m) => {
+ checks_passed += 1;
+ m
+ }
+ None => {
+ checks_failed += 1;
+ return UpgradeValidationResult {
+ passed: false,
+ checks_passed,
+ checks_failed,
+ summary: String::from_str(env, "no upgrade manifest"),
+ };
+ }
+ };
+
+ // Check 2: WASM hash matches.
+ if manifest.new_wasm_hash == *proposed_wasm_hash {
+ checks_passed += 1;
+ } else {
+ checks_failed += 1;
+ }
+
+ // Check 3: version is increasing.
+ let current = get_version(env);
+ if current.is_upgrade_of(&manifest.new_version) {
+ checks_passed += 1;
+ } else {
+ checks_failed += 1;
+ }
+
+ let passed = checks_failed == 0;
+ let summary = if passed {
+ String::from_str(env, "all checks passed")
+ } else {
+ String::from_str(env, "one or more checks failed")
+ };
+
+ emit_validation_ran(env, proposed_wasm_hash, passed, checks_passed, checks_failed);
+
+ UpgradeValidationResult {
+ passed,
+ checks_passed,
+ checks_failed,
+ summary,
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Execute upgrade bookkeeping
+// ---------------------------------------------------------------------------
+
+/// Record that an upgrade was successfully executed. Called immediately after
+/// `env.deployer().update_current_contract_wasm(...)` succeeds in `lib.rs`.
+///
+/// - Stores the old version/hash for rollback.
+/// - Writes the new version.
+/// - Appends to the history log.
+/// - Clears the manifest.
+pub fn record_upgrade(env: &Env, new_wasm_hash: &BytesN<32>, is_rollback: bool) {
+ let old_version = get_version(env);
+
+ // Persist previous WASM hash for rollback capability.
+ // We derive it from the manifest (the manifest holds the new hash;
+ // the current WASM hash is not directly readable on Soroban, so the
+ // admin must supply it via the manifest when rolling back).
+ let manifest = get_manifest(env);
+ let new_version = manifest
+ .as_ref()
+ .map(|m| m.new_version)
+ .unwrap_or(old_version);
+
+ env.storage().instance().set(&prev_version_key(), &old_version);
+ env.storage().instance().set(&prev_wasm_key(), new_wasm_hash);
+
+ set_version(env, new_version);
+ clear_manifest(env);
+
+ // Append to history (bounded).
+ let mut history: Vec = env
+ .storage()
+ .persistent()
+ .get(&history_key())
+ .unwrap_or_else(|| Vec::new(env));
+ history.push_back(UpgradeHistoryEntry {
+ from_version: old_version,
+ to_version: new_version,
+ wasm_hash: new_wasm_hash.clone(),
+ executed_at_ledger: env.ledger().sequence(),
+ executed_at_ts: env.ledger().timestamp(),
+ is_rollback,
+ });
+ while history.len() > MAX_UPGRADE_HISTORY {
+ history.pop_front();
+ }
+ env.storage().persistent().set(&history_key(), &history);
+
+ if is_rollback {
+ let count: u32 = env
+ .storage()
+ .instance()
+ .get(&rollback_count_key())
+ .unwrap_or(0u32);
+ env.storage()
+ .instance()
+ .set(&rollback_count_key(), &(count.saturating_add(1)));
+ }
+
+ emit_upgrade_recorded(env, new_wasm_hash, old_version, new_version, is_rollback);
+}
+
+/// Return the full upgrade history (most recent entries last).
+pub fn get_upgrade_history(env: &Env) -> Vec {
+ env.storage()
+ .persistent()
+ .get(&history_key())
+ .unwrap_or_else(|| Vec::new(env))
+}
+
+// ---------------------------------------------------------------------------
+// Rollback
+// ---------------------------------------------------------------------------
+
+/// Validate that a rollback is permitted.
+///
+/// Conditions:
+/// - A previous WASM hash must be stored.
+/// - The rollback counter must be below `MAX_ROLLBACK_COUNT`.
+pub fn assert_rollback_allowed(env: &Env) {
+ assert!(
+ get_previous_wasm_hash(env).is_some(),
+ "no previous WASM hash stored; rollback not possible"
+ );
+ let count: u32 = env
+ .storage()
+ .instance()
+ .get(&rollback_count_key())
+ .unwrap_or(0u32);
+ assert!(
+ count < MAX_ROLLBACK_COUNT,
+ "rollback limit reached; manual intervention required"
+ );
+}
+
+/// Reset the rollback counter (admin-only; called after a stable upgrade).
+pub fn reset_rollback_counter(env: &Env) {
+ env.storage().instance().set(&rollback_count_key(), &0u32);
+}
+
+/// Get current rollback count.
+pub fn get_rollback_count(env: &Env) -> u32 {
+ env.storage()
+ .instance()
+ .get(&rollback_count_key())
+ .unwrap_or(0u32)
+}
+
+// ---------------------------------------------------------------------------
+// Events
+// ---------------------------------------------------------------------------
+
+fn emit_manifest_proposed(env: &Env, wasm_hash: &BytesN<32>, new_version: ContractVersion) {
+ env.events().publish(
+ (symbol_short!("cu"), symbol_short!("mnf_prop")),
+ (
+ wasm_hash.clone(),
+ new_version.major,
+ new_version.minor,
+ new_version.patch,
+ ),
+ );
+}
+
+fn emit_validation_ran(
+ env: &Env,
+ wasm_hash: &BytesN<32>,
+ passed: bool,
+ checks_passed: u32,
+ checks_failed: u32,
+) {
+ env.events().publish(
+ (symbol_short!("cu"), symbol_short!("val_ran")),
+ (wasm_hash.clone(), passed, checks_passed, checks_failed),
+ );
+}
+
+fn emit_upgrade_recorded(
+ env: &Env,
+ new_wasm_hash: &BytesN<32>,
+ from: ContractVersion,
+ to: ContractVersion,
+ is_rollback: bool,
+) {
+ env.events().publish(
+ (symbol_short!("cu"), symbol_short!("upg_done")),
+ (
+ new_wasm_hash.clone(),
+ from.major,
+ from.minor,
+ from.patch,
+ to.major,
+ to.minor,
+ to.patch,
+ is_rollback,
+ ),
+ );
+}
diff --git a/contracts/split/src/emergency_pause.rs b/contracts/split/src/emergency_pause.rs
new file mode 100644
index 0000000..06529f9
--- /dev/null
+++ b/contracts/split/src/emergency_pause.rs
@@ -0,0 +1,647 @@
+//! Issue #1011: Enhanced emergency pause mechanism.
+//!
+//! Extends the basic pause/unpause in `lib.rs` with:
+//! - Named pause conditions that can be checked individually.
+//! - A recovery process requiring multi-step acknowledgement.
+//! - Notification hooks so external contracts are informed.
+//! - Analytics: pause/unpause counters, average pause duration.
+//!
+//! # Architecture
+//!
+//! The authoritative pause flag is still the instance-storage `paused` key
+//! managed by `lib.rs`. This module adds an **augmentation layer** that sits
+//! on top: it records *why* a pause was triggered, tracks recovery steps,
+//! and keeps analytics. Every entry point here ultimately calls into the
+//! existing `pause`/`unpause` helpers — it does not bypass them.
+//!
+//! ## Storage layout
+//!
+//! | Key symbol | Tier | Type | Purpose |
+//! |----------------|-------------|-------------------------|---------------------------------|
+//! | `ep_conds` | persistent | `Vec` | Registered named conditions |
+//! | `ep_active` | persistent | `Vec` | Currently triggered condition ids |
+//! | `ep_recovery` | persistent | `Option` | In-progress recovery plan |
+//! | `ep_notify` | persistent | `Vec` | Notification subscriber list |
+//! | `ep_analytics` | persistent | `PauseAnalytics` | Aggregate pause statistics |
+//! | `ep_last_ts` | persistent | `u64` | Timestamp of last pause start |
+
+use soroban_sdk::{contracttype, symbol_short, Address, Env, IntoVal, String, Symbol, Vec};
+
+use crate::error::ContractError;
+
+// ---------------------------------------------------------------------------
+// Limits
+// ---------------------------------------------------------------------------
+
+/// Maximum number of named pause conditions that can be registered.
+pub const MAX_PAUSE_CONDITIONS: u32 = 20;
+
+/// Maximum number of notification subscribers.
+pub const MAX_PAUSE_SUBSCRIBERS: u32 = 10;
+
+/// Maximum recovery steps in a plan.
+pub const MAX_RECOVERY_STEPS: u32 = 10;
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+/// Severity level for a pause condition.
+#[contracttype]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum PauseSeverity {
+ /// Informational — does not trigger an automatic pause.
+ Info = 0,
+ /// Warning — may trigger a partial/function-level pause.
+ Warning = 1,
+ /// Critical — triggers a full contract pause immediately.
+ Critical = 2,
+}
+
+/// A named, registered condition that can trigger an emergency pause.
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct PauseCondition {
+ /// Short identifier for this condition (e.g. `oracle_fail`).
+ pub id: Symbol,
+ /// Human-readable description of what triggers this condition.
+ pub description: String,
+ pub severity: PauseSeverity,
+ /// If `true`, triggering this condition immediately pauses the contract.
+ pub auto_pause: bool,
+ /// Address allowed to trigger this specific condition (None = any admin).
+ pub trigger_authority: Option,
+}
+
+/// A single step in the recovery plan.
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct RecoveryStep {
+ /// Human-readable description of what must be done in this step.
+ pub description: String,
+ /// Whether this step has been completed.
+ pub completed: bool,
+ /// Admin who completed this step (None if not yet completed).
+ pub completed_by: Option,
+ /// Timestamp when completed (0 if not yet).
+ pub completed_at: u64,
+}
+
+/// An active recovery plan that must be completed before unpause is allowed.
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct RecoveryPlan {
+ /// The condition IDs being recovered from.
+ pub condition_ids: Vec,
+ /// Steps that must all be completed before recovery is finalised.
+ pub steps: Vec,
+ /// Admin who initiated the recovery plan.
+ pub initiated_by: Address,
+ /// Ledger sequence when the plan was created.
+ pub created_at_ledger: u32,
+ /// Timestamp when the plan was created.
+ pub created_at_ts: u64,
+ /// Whether all steps are complete and the contract may be unpaused.
+ pub finalised: bool,
+}
+
+/// Aggregate analytics tracked across all pause/unpause events.
+#[contracttype]
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct PauseAnalytics {
+ /// Total number of times the contract has been paused (emergency or manual).
+ pub total_pause_count: u32,
+ /// Total number of times the contract has been unpaused.
+ pub total_unpause_count: u32,
+ /// Cumulative pause duration in seconds across all historical pauses.
+ pub cumulative_pause_secs: u64,
+ /// Timestamp of the most recent pause event (0 if never paused).
+ pub last_paused_at: u64,
+ /// Timestamp of the most recent unpause event (0 if never unpaused).
+ pub last_unpaused_at: u64,
+ /// Number of times the contract was paused due to a critical condition.
+ pub critical_trigger_count: u32,
+}
+
+// ---------------------------------------------------------------------------
+// Storage keys
+// ---------------------------------------------------------------------------
+
+fn conditions_key() -> Symbol {
+ symbol_short!("ep_conds")
+}
+
+fn active_conditions_key() -> Symbol {
+ symbol_short!("ep_active")
+}
+
+fn recovery_plan_key() -> Symbol {
+ symbol_short!("ep_recov")
+}
+
+fn subscribers_key() -> Symbol {
+ symbol_short!("ep_notify")
+}
+
+fn analytics_key() -> Symbol {
+ symbol_short!("ep_anlytc")
+}
+
+fn last_pause_ts_key() -> Symbol {
+ symbol_short!("ep_last_t")
+}
+
+// ---------------------------------------------------------------------------
+// Storage accessors
+// ---------------------------------------------------------------------------
+
+pub fn get_conditions(env: &Env) -> Vec {
+ env.storage()
+ .persistent()
+ .get(&conditions_key())
+ .unwrap_or_else(|| Vec::new(env))
+}
+
+fn set_conditions(env: &Env, conditions: &Vec) {
+ env.storage().persistent().set(&conditions_key(), conditions);
+}
+
+pub fn get_active_condition_ids(env: &Env) -> Vec {
+ env.storage()
+ .persistent()
+ .get(&active_conditions_key())
+ .unwrap_or_else(|| Vec::new(env))
+}
+
+fn set_active_conditions(env: &Env, ids: &Vec) {
+ env.storage().persistent().set(&active_conditions_key(), ids);
+}
+
+pub fn get_recovery_plan(env: &Env) -> Option {
+ env.storage().persistent().get(&recovery_plan_key())
+}
+
+fn set_recovery_plan(env: &Env, plan: &RecoveryPlan) {
+ env.storage().persistent().set(&recovery_plan_key(), plan);
+}
+
+fn remove_recovery_plan(env: &Env) {
+ env.storage().persistent().remove(&recovery_plan_key());
+}
+
+pub fn get_subscribers(env: &Env) -> Vec {
+ env.storage()
+ .persistent()
+ .get(&subscribers_key())
+ .unwrap_or_else(|| Vec::new(env))
+}
+
+fn set_subscribers(env: &Env, subs: &Vec) {
+ env.storage().persistent().set(&subscribers_key(), subs);
+}
+
+pub fn get_analytics(env: &Env) -> PauseAnalytics {
+ env.storage()
+ .persistent()
+ .get(&analytics_key())
+ .unwrap_or_default()
+}
+
+fn set_analytics(env: &Env, a: &PauseAnalytics) {
+ env.storage().persistent().set(&analytics_key(), a);
+}
+
+fn get_last_pause_ts(env: &Env) -> u64 {
+ env.storage()
+ .persistent()
+ .get(&last_pause_ts_key())
+ .unwrap_or(0u64)
+}
+
+fn set_last_pause_ts(env: &Env, ts: u64) {
+ env.storage().persistent().set(&last_pause_ts_key(), &ts);
+}
+
+// ---------------------------------------------------------------------------
+// Condition management
+// ---------------------------------------------------------------------------
+
+/// Register a new named pause condition. `admin` must be a super-admin.
+pub fn register_condition(
+ env: &Env,
+ admin: &Address,
+ id: Symbol,
+ description: String,
+ severity: PauseSeverity,
+ auto_pause: bool,
+ trigger_authority: Option,
+) {
+ let _ = admin; // auth is checked by the caller before delegating here
+ let mut conditions = get_conditions(env);
+ // Prevent duplicates.
+ for c in conditions.iter() {
+ assert!(c.id != id, "condition id already registered");
+ }
+ assert!(
+ conditions.len() < MAX_PAUSE_CONDITIONS,
+ "max pause conditions reached"
+ );
+ conditions.push_back(PauseCondition {
+ id: id.clone(),
+ description,
+ severity,
+ auto_pause,
+ trigger_authority,
+ });
+ set_conditions(env, &conditions);
+ emit_condition_registered(env, admin, &id, severity, auto_pause);
+}
+
+/// Remove a previously registered condition.
+pub fn unregister_condition(env: &Env, admin: &Address, id: &Symbol) {
+ let mut conditions = get_conditions(env);
+ let mut found = false;
+ let mut new_conditions = Vec::new(env);
+ for c in conditions.iter() {
+ if &c.id == id {
+ found = true;
+ } else {
+ new_conditions.push_back(c);
+ }
+ }
+ assert!(found, "condition not found");
+ set_conditions(env, &new_conditions);
+ emit_condition_unregistered(env, admin, id);
+}
+
+/// Retrieve a single condition by id.
+pub fn get_condition(env: &Env, id: &Symbol) -> Option {
+ for c in get_conditions(env).iter() {
+ if &c.id == id {
+ return Some(c);
+ }
+ }
+ None
+}
+
+// ---------------------------------------------------------------------------
+// Trigger / clear
+// ---------------------------------------------------------------------------
+
+/// Trigger a named condition. If `auto_pause` is set and the severity is
+/// Critical, the contract is also immediately paused via the instance flag.
+///
+/// Returns `true` when the call caused the contract to be paused.
+pub fn trigger_condition(
+ env: &Env,
+ caller: &Address,
+ condition_id: &Symbol,
+ // A mutable bool that the caller sets to reflect actual pause state.
+ is_currently_paused: bool,
+ set_paused_fn: &mut dyn FnMut(&Env, bool),
+) -> bool {
+ let condition = get_condition(env, condition_id)
+ .expect("condition not registered");
+
+ // Enforce trigger authority.
+ if let Some(ref authority) = condition.trigger_authority {
+ assert!(caller == authority, "caller not authorised to trigger condition");
+ }
+
+ // Mark condition as active.
+ let mut active = get_active_condition_ids(env);
+ if !active.iter().any(|a| &a == condition_id) {
+ active.push_back(condition_id.clone());
+ set_active_conditions(env, &active);
+ }
+
+ let mut newly_paused = false;
+
+ // Auto-pause for Critical severity.
+ if condition.auto_pause && condition.severity == PauseSeverity::Critical && !is_currently_paused {
+ set_paused_fn(env, true);
+ newly_paused = true;
+ record_pause_analytics(env);
+ notify_subscribers(env, caller, condition_id, true);
+ emit_emergency_pause_triggered(env, caller, condition_id, condition.severity);
+ } else {
+ emit_condition_triggered(env, caller, condition_id, condition.severity, is_currently_paused || newly_paused);
+ }
+
+ newly_paused
+}
+
+/// Clear a named condition (mark it as resolved). Removes it from the active set.
+pub fn clear_condition(env: &Env, admin: &Address, condition_id: &Symbol) {
+ let mut active = get_active_condition_ids(env);
+ let before_len = active.len();
+ let mut new_active = Vec::new(env);
+ for a in active.iter() {
+ if &a != condition_id {
+ new_active.push_back(a);
+ }
+ }
+ assert!(new_active.len() < before_len, "condition not active");
+ set_active_conditions(env, &new_active);
+ emit_condition_cleared(env, admin, condition_id);
+}
+
+/// True if any condition with severity ≥ `min_severity` is currently active.
+pub fn has_active_conditions_at_severity(env: &Env, min_severity: PauseSeverity) -> bool {
+ let active = get_active_condition_ids(env);
+ if active.is_empty() {
+ return false;
+ }
+ for id in active.iter() {
+ if let Some(c) = get_condition(env, &id) {
+ if (c.severity as u32) >= (min_severity as u32) {
+ return true;
+ }
+ }
+ }
+ false
+}
+
+// ---------------------------------------------------------------------------
+// Recovery
+// ---------------------------------------------------------------------------
+
+/// Initiate a recovery plan. This is mandatory before unpausing when active
+/// Critical conditions exist. Providing steps forces admins to document the
+/// remediation work.
+pub fn initiate_recovery(
+ env: &Env,
+ admin: &Address,
+ steps: Vec,
+) {
+ assert!(
+ get_recovery_plan(env).is_none(),
+ "recovery plan already active"
+ );
+ assert!(!steps.is_empty(), "recovery plan must have at least one step");
+ assert!(
+ steps.len() <= MAX_RECOVERY_STEPS as u32,
+ "too many recovery steps"
+ );
+
+ let active = get_active_condition_ids(env);
+ let mut recovery_steps = Vec::new(env);
+ for desc in steps.iter() {
+ recovery_steps.push_back(RecoveryStep {
+ description: desc,
+ completed: false,
+ completed_by: None,
+ completed_at: 0,
+ });
+ }
+
+ let plan = RecoveryPlan {
+ condition_ids: active,
+ steps: recovery_steps,
+ initiated_by: admin.clone(),
+ created_at_ledger: env.ledger().sequence(),
+ created_at_ts: env.ledger().timestamp(),
+ finalised: false,
+ };
+ set_recovery_plan(env, &plan);
+ emit_recovery_initiated(env, admin, plan.steps.len());
+}
+
+/// Mark a recovery step as completed. Steps are 0-indexed.
+pub fn complete_recovery_step(env: &Env, admin: &Address, step_index: u32) {
+ let mut plan = get_recovery_plan(env).expect("no active recovery plan");
+ assert!(!plan.finalised, "recovery plan already finalised");
+ assert!(
+ (step_index as u32) < plan.steps.len(),
+ "invalid step index"
+ );
+ let mut step: RecoveryStep = plan.steps.get(step_index).unwrap();
+ assert!(!step.completed, "step already completed");
+ step.completed = true;
+ step.completed_by = Some(admin.clone());
+ step.completed_at = env.ledger().timestamp();
+ plan.steps.set(step_index, step);
+
+ // Check if all steps are now complete.
+ let all_done = plan.steps.iter().all(|s| s.completed);
+ if all_done {
+ plan.finalised = true;
+ emit_recovery_finalised(env, admin);
+ }
+
+ set_recovery_plan(env, &plan);
+ emit_recovery_step_completed(env, admin, step_index);
+}
+
+/// Discard an in-progress recovery plan (e.g. if the situation changes).
+pub fn cancel_recovery(env: &Env, admin: &Address) {
+ assert!(get_recovery_plan(env).is_some(), "no active recovery plan");
+ remove_recovery_plan(env);
+ emit_recovery_cancelled(env, admin);
+}
+
+/// Check that recovery prerequisites are met before allowing an unpause.
+/// Returns `Err(ContractError::RecoveryIncomplete)` if they are not.
+pub fn assert_recovery_complete(env: &Env) -> Result<(), ContractError> {
+ // If there are active Critical conditions, a finalised recovery plan is required.
+ if has_active_conditions_at_severity(env, PauseSeverity::Critical) {
+ match get_recovery_plan(env) {
+ Some(plan) if plan.finalised => Ok(()),
+ _ => Err(ContractError::RecoveryIncomplete),
+ }
+ } else {
+ // No critical conditions — no recovery plan required.
+ Ok(())
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Notification subscribers
+// ---------------------------------------------------------------------------
+
+/// Subscribe an external contract to pause/unpause notifications.
+pub fn add_subscriber(env: &Env, admin: &Address, subscriber: Address) {
+ let _ = admin;
+ let mut subs = get_subscribers(env);
+ if subs.iter().any(|s| s == subscriber) {
+ return; // idempotent
+ }
+ assert!(subs.len() < MAX_PAUSE_SUBSCRIBERS, "subscriber list full");
+ subs.push_back(subscriber.clone());
+ set_subscribers(env, &subs);
+ emit_subscriber_added(env, admin, &subscriber);
+}
+
+/// Remove a notification subscriber.
+pub fn remove_subscriber(env: &Env, admin: &Address, subscriber: &Address) {
+ let mut subs = get_subscribers(env);
+ let mut new_subs = Vec::new(env);
+ let mut found = false;
+ for s in subs.iter() {
+ if &s == subscriber {
+ found = true;
+ } else {
+ new_subs.push_back(s);
+ }
+ }
+ assert!(found, "subscriber not found");
+ set_subscribers(env, &new_subs);
+ emit_subscriber_removed(env, admin, subscriber);
+}
+
+/// Dispatch notifications to all registered subscribers.
+/// Each subscriber is called with `(paused: bool, condition_id: Symbol)`.
+/// Failures are swallowed so a bad subscriber cannot block the pause.
+pub fn notify_subscribers(env: &Env, actor: &Address, condition_id: &Symbol, paused: bool) {
+ let _ = actor;
+ let subs = get_subscribers(env);
+ for sub in subs.iter() {
+ // Best-effort: ignore any error from a subscriber.
+ let _ = env.try_invoke_contract::<(), soroban_sdk::Error>(
+ &sub,
+ &symbol_short!("on_pause"),
+ (paused, condition_id.clone()).into_val(env),
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Analytics
+// ---------------------------------------------------------------------------
+
+/// Record that a pause just started. Called when the contract transitions to
+/// paused state via this module.
+pub fn record_pause_analytics(env: &Env) {
+ let now = env.ledger().timestamp();
+ let mut a = get_analytics(env);
+ a.total_pause_count = a.total_pause_count.saturating_add(1);
+ a.last_paused_at = now;
+ set_analytics(env, &a);
+ set_last_pause_ts(env, now);
+}
+
+/// Record that the contract was just unpaused. Accumulates the pause duration.
+pub fn record_unpause_analytics(env: &Env) {
+ let now = env.ledger().timestamp();
+ let last = get_last_pause_ts(env);
+ let duration = if last > 0 && now > last { now - last } else { 0 };
+ let mut a = get_analytics(env);
+ a.total_unpause_count = a.total_unpause_count.saturating_add(1);
+ a.cumulative_pause_secs = a.cumulative_pause_secs.saturating_add(duration);
+ a.last_unpaused_at = now;
+ set_analytics(env, &a);
+}
+
+/// Increment the critical-trigger counter in analytics.
+pub fn record_critical_trigger(env: &Env) {
+ let mut a = get_analytics(env);
+ a.critical_trigger_count = a.critical_trigger_count.saturating_add(1);
+ set_analytics(env, &a);
+}
+
+/// Average pause duration in seconds (0 if never unpaused).
+pub fn average_pause_duration_secs(env: &Env) -> u64 {
+ let a = get_analytics(env);
+ if a.total_unpause_count == 0 {
+ return 0;
+ }
+ a.cumulative_pause_secs / a.total_unpause_count as u64
+}
+
+// ---------------------------------------------------------------------------
+// Events
+// ---------------------------------------------------------------------------
+
+fn emit_condition_registered(
+ env: &Env,
+ admin: &Address,
+ id: &Symbol,
+ severity: PauseSeverity,
+ auto_pause: bool,
+) {
+ env.events().publish(
+ (symbol_short!("ep"), symbol_short!("cond_reg")),
+ (admin.clone(), id.clone(), severity as u32, auto_pause),
+ );
+}
+
+fn emit_condition_unregistered(env: &Env, admin: &Address, id: &Symbol) {
+ env.events().publish(
+ (symbol_short!("ep"), symbol_short!("cond_del")),
+ (admin.clone(), id.clone()),
+ );
+}
+
+fn emit_condition_triggered(
+ env: &Env,
+ caller: &Address,
+ id: &Symbol,
+ severity: PauseSeverity,
+ contract_paused: bool,
+) {
+ env.events().publish(
+ (symbol_short!("ep"), symbol_short!("cond_trg")),
+ (caller.clone(), id.clone(), severity as u32, contract_paused),
+ );
+}
+
+fn emit_emergency_pause_triggered(
+ env: &Env,
+ caller: &Address,
+ condition_id: &Symbol,
+ severity: PauseSeverity,
+) {
+ env.events().publish(
+ (symbol_short!("ep"), symbol_short!("emrg_pau")),
+ (caller.clone(), condition_id.clone(), severity as u32, env.ledger().timestamp()),
+ );
+}
+
+fn emit_condition_cleared(env: &Env, admin: &Address, id: &Symbol) {
+ env.events().publish(
+ (symbol_short!("ep"), symbol_short!("cond_clr")),
+ (admin.clone(), id.clone()),
+ );
+}
+
+fn emit_recovery_initiated(env: &Env, admin: &Address, step_count: u32) {
+ env.events().publish(
+ (symbol_short!("ep"), symbol_short!("recov_ini")),
+ (admin.clone(), step_count),
+ );
+}
+
+fn emit_recovery_step_completed(env: &Env, admin: &Address, step_index: u32) {
+ env.events().publish(
+ (symbol_short!("ep"), symbol_short!("recov_stp")),
+ (admin.clone(), step_index),
+ );
+}
+
+fn emit_recovery_finalised(env: &Env, admin: &Address) {
+ env.events().publish(
+ (symbol_short!("ep"), symbol_short!("recov_fin")),
+ (admin.clone(), env.ledger().timestamp()),
+ );
+}
+
+fn emit_recovery_cancelled(env: &Env, admin: &Address) {
+ env.events().publish(
+ (symbol_short!("ep"), symbol_short!("recov_cxl")),
+ admin.clone(),
+ );
+}
+
+fn emit_subscriber_added(env: &Env, admin: &Address, subscriber: &Address) {
+ env.events().publish(
+ (symbol_short!("ep"), symbol_short!("sub_add")),
+ (admin.clone(), subscriber.clone()),
+ );
+}
+
+fn emit_subscriber_removed(env: &Env, admin: &Address, subscriber: &Address) {
+ env.events().publish(
+ (symbol_short!("ep"), symbol_short!("sub_del")),
+ (admin.clone(), subscriber.clone()),
+ );
+}
diff --git a/contracts/split/src/error.rs b/contracts/split/src/error.rs
index ef7b6dd..7e8e20f 100644
--- a/contracts/split/src/error.rs
+++ b/contracts/split/src/error.rs
@@ -171,6 +171,8 @@ pub enum ContractError {
MissingStorageEntry = 75,
/// Issue #951: The requested operation is not supported by this contract version.
UnsupportedOperation = 76,
+ /// Issue #1011: Emergency pause recovery plan is not yet complete; unpause is blocked.
+ RecoveryIncomplete = 77,
}
/// Issue #952: Human-readable, actionable recovery suggestions for every
@@ -385,6 +387,9 @@ pub mod recovery {
ContractError::TooFewRecipients => {
"Add more recipients to meet the configured minimum recipient count."
}
+ ContractError::RecoveryIncomplete => {
+ "Complete all recovery plan steps before unpausing the contract."
+ }
}
}
diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs
index f176d51..1f6358b 100644
--- a/contracts/split/src/lib.rs
+++ b/contracts/split/src/lib.rs
@@ -121,6 +121,15 @@ mod sustainability;
mod cooperative;
mod education_lockup;
+// Issue #1011: Enhanced emergency pause mechanism.
+pub mod emergency_pause;
+// Issue #1010: Enhanced contract upgrade mechanism with version management.
+pub mod contract_upgrade;
+// Issue #1009: Comprehensive audit logging for compliance.
+pub mod audit_logging;
+// Issue #1008: Enhanced per-user rate limiting and throttling.
+pub mod rate_limiting;
+
use error::ContractError;
use validation::assert_valid_bps;
use calc::{calc_platform_fee, funding_bps};
diff --git a/contracts/split/src/rate_limiting.rs b/contracts/split/src/rate_limiting.rs
new file mode 100644
index 0000000..b619120
--- /dev/null
+++ b/contracts/split/src/rate_limiting.rs
@@ -0,0 +1,415 @@
+//! Issue #1008: Enhanced rate limiting and throttling.
+//!
+//! Provides per-user, per-operation rate limiting with exponential backoff,
+//! accurate tracking, and analytics — extending the simpler per-invoice
+//! sliding-window limiter already in `lib.rs`.
+//!
+//! # Design
+//!
+//! The limiter uses a **sliding-window counter** approach:
+//! - Each (user, operation) pair has a `RateLimitState` stored in persistent
+//! storage.
+//! - The state records how many calls the user made in the current window and
+//! when that window started.
+//! - When a window expires (`now >= window_start + window_secs`), the counter
+//! is reset.
+//!
+//! Backoff is implemented as an **exponential penalty** on top of the base
+//! limit: a user who has hit the limit N times in a row must wait
+//! `base_window_secs * 2^N` seconds before the limit resets for them.
+//!
+//! ## Storage layout
+//!
+//! | Key symbol | Tier | Type | Purpose |
+//! |-------------------|------------|-------------------------------|------------------------------------|
+//! | `rl_cfg(op)` | persistent | `RateLimitConfig` | Per-operation config |
+//! | `rl_state(op, u)` | persistent | `RateLimitState` | Per-(operation, user) state |
+//! | `rl_analytics` | instance | `RateLimitAnalytics` | Aggregate analytics |
+//! | `rl_ops` | instance | `Vec` | Registered operation names |
+
+use soroban_sdk::{contracttype, symbol_short, Address, Env, Symbol, Vec};
+
+use crate::error::ContractError;
+
+// ---------------------------------------------------------------------------
+// Limits
+// ---------------------------------------------------------------------------
+
+/// Maximum number of distinct operations that can be rate-limited.
+pub const MAX_RATE_LIMITED_OPS: u32 = 20;
+
+/// Maximum backoff multiplier (caps the exponential growth).
+pub const MAX_BACKOFF_MULTIPLIER: u32 = 32;
+
+/// Default max calls per window when a config is not set (0 = unlimited).
+pub const DEFAULT_MAX_CALLS: u32 = 0;
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+/// Configuration for one rate-limited operation.
+#[contracttype]
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct RateLimitConfig {
+ /// Maximum number of calls allowed per window. `0` = no limit.
+ pub max_calls: u32,
+ /// Window duration in seconds. Required when `max_calls > 0`.
+ pub window_secs: u64,
+ /// Enable exponential backoff on repeated violations.
+ pub backoff_enabled: bool,
+ /// Maximum number of consecutive violations before the backoff cap applies.
+ pub max_backoff_steps: u32,
+}
+
+impl RateLimitConfig {
+ /// True when this config has an active limit.
+ pub fn is_active(&self) -> bool {
+ self.max_calls > 0 && self.window_secs > 0
+ }
+}
+
+/// Per-(operation, user) sliding-window state.
+#[contracttype]
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct RateLimitState {
+ /// Timestamp when the current window started.
+ pub window_start: u64,
+ /// Number of calls made in the current window.
+ pub call_count: u32,
+ /// Number of consecutive windows in which the user hit the limit.
+ pub violation_streak: u32,
+ /// Timestamp of the most recent call (0 if none).
+ pub last_call_at: u64,
+}
+
+/// Aggregate analytics collected across all rate-limited operations.
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct RateLimitAnalytics {
+ /// Total calls checked (allowed + denied).
+ pub total_checked: u64,
+ /// Total calls that were allowed through.
+ pub total_allowed: u64,
+ /// Total calls that were denied.
+ pub total_denied: u64,
+ /// Total times a backoff penalty was applied.
+ pub total_backoffs: u64,
+ /// Count of calls per operation (keyed by Symbol's string representation).
+ // Note: we use a u32-keyed map indexed by a hash for no_std compat.
+ pub denied_by_op: soroban_sdk::Map,
+}
+
+/// Result of a rate-limit check.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum CheckResult {
+ /// Allowed; `remaining` is the calls left in this window.
+ Allowed { remaining: u32 },
+ /// Denied; `retry_after_secs` is the minimum wait.
+ Denied { retry_after_secs: u64 },
+}
+
+// ---------------------------------------------------------------------------
+// Storage keys
+// ---------------------------------------------------------------------------
+
+fn config_key(op: &Symbol) -> (Symbol, Symbol) {
+ (symbol_short!("rl_cfg"), op.clone())
+}
+
+fn state_key(op: &Symbol, user: &Address) -> (Symbol, Symbol, Address) {
+ (symbol_short!("rl_st"), op.clone(), user.clone())
+}
+
+fn analytics_key() -> Symbol {
+ symbol_short!("rl_anlytc")
+}
+
+fn ops_key() -> Symbol {
+ symbol_short!("rl_ops")
+}
+
+// ---------------------------------------------------------------------------
+// Configuration management
+// ---------------------------------------------------------------------------
+
+/// Register or update the rate-limit configuration for an operation.
+pub fn configure(env: &Env, op: &Symbol, config: &RateLimitConfig) {
+ if config.is_active() {
+ assert!(
+ config.window_secs > 0,
+ "window_secs must be positive when max_calls > 0"
+ );
+ }
+ if config.backoff_enabled && config.max_backoff_steps == 0 {
+ // Default to 4 backoff steps when enabled but not configured.
+ }
+
+ env.storage().persistent().set(&config_key(op), config);
+
+ // Track registered operations.
+ let mut ops: Vec = env
+ .storage()
+ .instance()
+ .get(&ops_key())
+ .unwrap_or_else(|| Vec::new(env));
+ if !ops.iter().any(|o| &o == op) {
+ assert!(
+ ops.len() < MAX_RATE_LIMITED_OPS,
+ "max rate-limited operations reached"
+ );
+ ops.push_back(op.clone());
+ env.storage().instance().set(&ops_key(), &ops);
+ }
+
+ emit_config_set(env, op, config.max_calls, config.window_secs, config.backoff_enabled);
+}
+
+/// Remove the rate-limit configuration for an operation (disables limiting).
+pub fn remove_config(env: &Env, op: &Symbol) {
+ env.storage().persistent().remove(&config_key(op));
+ let mut ops: Vec = env
+ .storage()
+ .instance()
+ .get(&ops_key())
+ .unwrap_or_else(|| Vec::new(env));
+ let mut new_ops = Vec::new(env);
+ for o in ops.iter() {
+ if &o != op {
+ new_ops.push_back(o);
+ }
+ }
+ env.storage().instance().set(&ops_key(), &new_ops);
+}
+
+/// Return the config for an operation, or a permissive default (no limit).
+pub fn get_config(env: &Env, op: &Symbol) -> RateLimitConfig {
+ env.storage()
+ .persistent()
+ .get(&config_key(op))
+ .unwrap_or_default()
+}
+
+/// Return all registered operation names.
+pub fn get_operations(env: &Env) -> Vec {
+ env.storage()
+ .instance()
+ .get(&ops_key())
+ .unwrap_or_else(|| Vec::new(env))
+}
+
+// ---------------------------------------------------------------------------
+// Per-user state
+// ---------------------------------------------------------------------------
+
+pub fn get_state(env: &Env, op: &Symbol, user: &Address) -> RateLimitState {
+ env.storage()
+ .persistent()
+ .get(&state_key(op, user))
+ .unwrap_or_default()
+}
+
+fn set_state(env: &Env, op: &Symbol, user: &Address, state: &RateLimitState) {
+ env.storage().persistent().set(&state_key(op, user), state);
+}
+
+/// Reset the rate-limit state for a specific (operation, user) pair.
+/// Useful for admin overrides (e.g. whitelisted users after a manual review).
+pub fn reset_user_state(env: &Env, op: &Symbol, user: &Address) {
+ env.storage().persistent().remove(&state_key(op, user));
+ emit_user_state_reset(env, op, user);
+}
+
+// ---------------------------------------------------------------------------
+// Core check / record
+// ---------------------------------------------------------------------------
+
+/// Compute the effective window duration applying backoff if configured.
+fn effective_window_secs(config: &RateLimitConfig, streak: u32) -> u64 {
+ if !config.backoff_enabled || streak == 0 {
+ return config.window_secs;
+ }
+ let multiplier = 1u32 << streak.min(MAX_BACKOFF_MULTIPLIER);
+ config
+ .window_secs
+ .saturating_mul(multiplier.min(MAX_BACKOFF_MULTIPLIER) as u64)
+}
+
+/// Check whether `user` may make another call to `op` right now.
+///
+/// This is a pure check: it reads state but does NOT write. Call
+/// [`record_call`] after a successful call to commit the counter increment.
+pub fn check(env: &Env, op: &Symbol, user: &Address) -> CheckResult {
+ let config = get_config(env, op);
+ if !config.is_active() {
+ return CheckResult::Allowed {
+ remaining: u32::MAX,
+ };
+ }
+
+ let now = env.ledger().timestamp();
+ let state = get_state(env, op, user);
+ let window_secs = effective_window_secs(&config, state.violation_streak);
+ let window_end = state.window_start.saturating_add(window_secs);
+
+ if now >= window_end {
+ // Window has expired → user gets a fresh window.
+ return CheckResult::Allowed {
+ remaining: config.max_calls.saturating_sub(1),
+ };
+ }
+
+ if state.call_count < config.max_calls {
+ return CheckResult::Allowed {
+ remaining: config.max_calls - state.call_count - 1,
+ };
+ }
+
+ // Limit hit.
+ let retry_after = window_end.saturating_sub(now);
+ CheckResult::Denied { retry_after_secs: retry_after }
+}
+
+/// Record a successful call by `user` for `op`, updating the sliding window.
+/// Call this immediately after `check` returns `Allowed`.
+pub fn record_call(env: &Env, op: &Symbol, user: &Address) {
+ let config = get_config(env, op);
+ if !config.is_active() {
+ return;
+ }
+
+ let now = env.ledger().timestamp();
+ let mut state = get_state(env, op, user);
+ let window_secs = effective_window_secs(&config, state.violation_streak);
+ let window_end = state.window_start.saturating_add(window_secs);
+
+ if now >= window_end {
+ // New window — reset count. Streak is cleared whenever a new window
+ // starts without a denial (call_count was below max when the window expired).
+ let was_under_limit = state.call_count < config.max_calls;
+ state.window_start = now;
+ state.call_count = 1;
+ if was_under_limit {
+ state.violation_streak = 0;
+ }
+ } else {
+ state.call_count = state.call_count.saturating_add(1);
+ }
+
+ state.last_call_at = now;
+ set_state(env, op, user, &state);
+
+ // Update analytics.
+ let mut analytics = get_analytics(env);
+ analytics.total_checked = analytics.total_checked.saturating_add(1);
+ analytics.total_allowed = analytics.total_allowed.saturating_add(1);
+ set_analytics(env, &analytics);
+}
+
+/// Enforce the rate limit for `user` on `op`.
+///
+/// - If allowed: calls `record_call` and returns `Ok(remaining)`.
+/// - If denied: emits a denial event, updates analytics, and returns
+/// `Err(ContractError::RateLimitExceeded)`.
+pub fn enforce(env: &Env, op: &Symbol, user: &Address) -> Result {
+ let result = check(env, op, user);
+ match result {
+ CheckResult::Allowed { remaining } => {
+ record_call(env, op, user);
+ emit_call_allowed(env, op, user, remaining);
+ Ok(remaining)
+ }
+ CheckResult::Denied { retry_after_secs } => {
+ // Record violation streak.
+ let config = get_config(env, op);
+ let mut state = get_state(env, op, user);
+ let max_streak = config.max_backoff_steps.max(1);
+ if config.backoff_enabled && state.violation_streak < max_streak {
+ state.violation_streak = state.violation_streak.saturating_add(1);
+ set_state(env, op, user, &state);
+ }
+
+ // Update analytics.
+ let mut analytics = get_analytics(env);
+ analytics.total_checked = analytics.total_checked.saturating_add(1);
+ analytics.total_denied = analytics.total_denied.saturating_add(1);
+ if config.backoff_enabled {
+ analytics.total_backoffs = analytics.total_backoffs.saturating_add(1);
+ }
+ let op_denied = analytics.denied_by_op.get(op.clone()).unwrap_or(0u64);
+ analytics.denied_by_op.set(op.clone(), op_denied + 1);
+ set_analytics(env, &analytics);
+
+ emit_call_denied(env, op, user, retry_after_secs);
+ Err(ContractError::RateLimitExceeded)
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Analytics
+// ---------------------------------------------------------------------------
+
+pub fn get_analytics(env: &Env) -> RateLimitAnalytics {
+ env.storage()
+ .instance()
+ .get(&analytics_key())
+ .unwrap_or_else(|| RateLimitAnalytics {
+ total_checked: 0,
+ total_allowed: 0,
+ total_denied: 0,
+ total_backoffs: 0,
+ denied_by_op: soroban_sdk::Map::new(env),
+ })
+}
+
+fn set_analytics(env: &Env, a: &RateLimitAnalytics) {
+ env.storage().instance().set(&analytics_key(), a);
+}
+
+/// Denial rate in basis points (0–10000). Returns 0 if no calls checked.
+pub fn denial_rate_bps(env: &Env) -> u32 {
+ let a = get_analytics(env);
+ if a.total_checked == 0 {
+ return 0;
+ }
+ ((a.total_denied * 10_000) / a.total_checked) as u32
+}
+
+// ---------------------------------------------------------------------------
+// Events
+// ---------------------------------------------------------------------------
+
+fn emit_config_set(
+ env: &Env,
+ op: &Symbol,
+ max_calls: u32,
+ window_secs: u64,
+ backoff_enabled: bool,
+) {
+ env.events().publish(
+ (symbol_short!("rl"), symbol_short!("cfg_set")),
+ (op.clone(), max_calls, window_secs, backoff_enabled),
+ );
+}
+
+fn emit_call_allowed(env: &Env, op: &Symbol, user: &Address, remaining: u32) {
+ env.events().publish(
+ (symbol_short!("rl"), symbol_short!("allowed")),
+ (op.clone(), user.clone(), remaining),
+ );
+}
+
+fn emit_call_denied(env: &Env, op: &Symbol, user: &Address, retry_after_secs: u64) {
+ env.events().publish(
+ (symbol_short!("rl"), symbol_short!("denied")),
+ (op.clone(), user.clone(), retry_after_secs),
+ );
+}
+
+fn emit_user_state_reset(env: &Env, op: &Symbol, user: &Address) {
+ env.events().publish(
+ (symbol_short!("rl"), symbol_short!("st_reset")),
+ (op.clone(), user.clone()),
+ );
+}