diff --git a/contracts/predictify-hybrid/src/event_topic_compat.rs b/contracts/predictify-hybrid/src/event_topic_compat.rs new file mode 100644 index 00000000..144307f8 --- /dev/null +++ b/contracts/predictify-hybrid/src/event_topic_compat.rs @@ -0,0 +1,591 @@ +//! Event topic compatibility layer for contract upgrades (issue #1391). +//! +//! # Problem +//! +//! Soroban event topics are emitted as raw `symbol_short!()` literals scattered +//! across every emit site. When a topic is renamed or a schema version bumps, +//! off-chain indexers and integrators that filter by topic stop receiving events +//! without warning. There is also no mechanism to simultaneously publish under +//! both the *old* and *new* topic symbol during a rolling upgrade window. +//! +//! # Solution +//! +//! This module provides: +//! +//! 1. **[`EventTopicRegistry`]** – a single, authoritative table of every event +//! topic emitted by the contract, with the current topic `Symbol`, a +//! monotonically-increasing `schema_version`, and a human-readable +//! `description`. All emit sites **must** resolve their topic through this +//! registry rather than hard-coding `symbol_short!()` inline. +//! +//! 2. **[`EventCompatBridge`]** – dual-publish helper that, during a +//! *compatibility window*, emits a single logical event under **both** the +//! previous topic symbol and the current topic symbol. Consumers can migrate +//! to the new topic at their own pace. +//! +//! 3. **[`TopicAlias`] / [`DataKey::EventTopicAlias`]** – persistent storage +//! entries that map a superseded topic symbol to its replacement. Written +//! by the upgrade hook so that any on-chain consumer that reads aliases can +//! discover the rename automatically. +//! +//! 4. **[`EventNonceGuard`]** – helpers invoked from [`UpgradeManager`] to +//! snapshot and restore all per-topic event nonces so that the +//! monotonically-increasing guarantee survives contract upgrades even if +//! persistent storage is partially re-initialised. +//! +//! # Invariants +//! +//! * `EventTopicRegistry::get` never panics; it returns `None` for unknown +//! names so callers can fall back gracefully. +//! * `EventCompatBridge::publish_with_compat` is idempotent: calling it twice +//! with the same `(old_topic, new_topic, data)` tuple emits two *independent* +//! on-chain events (Soroban events are append-only), but does **not** corrupt +//! storage or nonces. +//! * Nonce preservation is atomic within a single Soroban transaction: either +//! all nonces are snapshotted/restored or none are (transaction rolls back on +//! any panic). +//! * `schema_version` is bumped in this table **only**. All emit sites delegate +//! here, so a single constant change propagates everywhere. + +#![allow(dead_code)] + +use soroban_sdk::{contracttype, Env, Map, String, Symbol, Vec}; + +use crate::storage::DataKey; + +// ───────────────────────────────────────────────────────────────────────────── +// Topic version constants +// +// Every entry below follows the convention: +// pub const TOPIC_: (&str, u32) = ("symbol", schema_version); +// +// When a schema-breaking change is made to an event payload: +// 1. Bump `schema_version` here. +// 2. Add the previous symbol to `ALIASES` below. +// 3. The upgrade hook will call `EventNonceGuard::preserve_nonces` and +// `EventCompatBridge::register_aliases` automatically. +// ───────────────────────────────────────────────────────────────────────────── + +/// Compile-time registry of every (topic_symbol, schema_version) pair. +/// +/// The first tuple element is the `symbol_short!()` string; the second is the +/// current schema version. This table is the **single source of truth** for +/// all emit sites. +pub const TOPIC_REGISTRY: &[(&str, u32, &str)] = &[ + // ── Market lifecycle ────────────────────────────────────────────────── + ("mkt_crt", 1, "market_created"), + ("evt_crt", 1, "event_created"), + ("mkt_close", 1, "market_closed"), + ("mkt_final", 1, "market_finalized"), + ("st_chng", 1, "state_changed"), + ("mkt_ext", 1, "market_deadline_extended"), + ("mkt_dsc", 1, "market_description_updated"), + ("mkt_out", 1, "market_outcomes_updated"), + ("mkt_cat", 1, "category_updated"), + ("mkt_tag", 1, "tags_updated"), + ("ext_req", 1, "extension_requested"), + ("pool_lo", 1, "min_pool_size_not_met"), + ("ref_oracl", 1, "refund_on_oracle_failure"), + ("mkt_arch", 1, "market_archived"), + ("mkt_rem", 1, "market_removed"), + ("mkt_tier", 1, "market_tier_changed"), + // ── Betting ─────────────────────────────────────────────────────────── + ("bet_plc", 1, "bet_placed"), + ("bet_upd", 1, "bet_status_updated"), + ("bet_lim", 1, "bet_limit_set"), + ("cap_set", 1, "max_bet_cap_set"), + ("cap_excd", 1, "bet_cap_exceeded"), + ("mxbtcap", 1, "per_ledger_bet_cap_set"), + ("cum_cap", 1, "cumulative_bet_cap_reached"), + ("cum_set", 1, "cumulative_bet_cap_set"), + // ── Voting ──────────────────────────────────────────────────────────── + ("vote", 1, "vote_cast"), + ("gov_vote", 1, "governance_vote"), + ("gov_prop", 1, "governance_proposal"), + ("gov_cmit", 1, "governance_committed"), + ("gov_exec", 1, "governance_executed"), + ("gov_rej", 1, "governance_rejected"), + // ── Resolution ──────────────────────────────────────────────────────── + ("mkt_res", 1, "market_resolved"), + ("auto_res", 1, "auto_resolved"), + ("man_res", 1, "manual_resolution_required"), + ("frc_rs", 1, "force_resolved"), + // ── Oracle ──────────────────────────────────────────────────────────── + ("oracle_rs", 1, "oracle_result"), + ("orc_init", 1, "oracle_verification_initiated"), + ("orc_ver", 1, "oracle_result_verified"), + ("orc_fail", 1, "oracle_verification_failed"), + ("orc_val", 1, "oracle_validation_failed"), + ("orc_res", 1, "oracle_result_fetched"), + ("orc_hlth", 1, "oracle_health"), + ("orc_cons", 1, "oracle_consensus"), + ("orc_med_q", 1, "oracle_median_queried"), + ("ora_deg", 1, "oracle_degraded"), + ("ora_rec", 1, "oracle_recovered"), + ("fbk_used", 1, "fallback_used"), + ("res_tmo", 1, "resolution_timeout"), + // ── Disputes ────────────────────────────────────────────────────────── + ("dispt_opn", 1, "dispute_opened"), + ("dispt_crt", 1, "dispute_created"), + ("dispt_res", 1, "dispute_resolved"), + ("d_v_rej", 1, "dispute_vote_rejected"), + ("sus_col", 1, "suspicious_collusion"), + // ── Fees & treasury ─────────────────────────────────────────────────── + ("fee_col", 1, "fee_collected"), + ("fee_qd", 1, "fee_config_queued"), + ("fee_apd", 1, "fee_config_applied"), + ("fee_ccl", 1, "fee_config_cancelled"), + ("treas_up", 1, "treasury_updated"), + ("tsu_qd", 1, "treasury_update_queued"), + ("tsu_apd", 1, "treasury_update_applied"), + ("tsu_ccl", 1, "treasury_update_cancelled"), + ("pay_rem", 1, "payout_remainder_allocated"), + ("unc_swip", 1, "unclaimed_winnings_swept"), + ("win_clm", 1, "winnings_claimed"), + ("win_btc", 1, "winnings_batched"), + ("m_clm_pd", 1, "market_claims_paid_out"), + ("clm_prd", 1, "claim_period_expired"), + // ── Admin & access control ──────────────────────────────────────────── + ("adm_init", 1, "admin_initialised"), + ("adm_act", 1, "admin_action"), + ("adm_role", 1, "admin_role_set"), + ("adm_xfer", 1, "admin_transferred"), + ("adm_ovrd", 1, "admin_override"), + ("adm_deact", 1, "admin_deactivated"), + ("adm_brdc", 1, "admin_broadcast"), + ("allowlst", 1, "allowlist_updated"), + // ── Storage & upgrade ───────────────────────────────────────────────── + ("st_tier", 1, "storage_tier_changed"), + ("stor_cln", 1, "storage_cleaned"), + ("stor_mig", 1, "storage_migrated"), + ("stor_opt", 1, "storage_optimised"), + ("up_grade", 1, "contract_upgraded"), + ("up_prop", 1, "upgrade_proposed"), + ("rollback", 1, "upgrade_rolled_back"), + ("arch_trn", 1, "archive_transition"), + ("rest_trn", 1, "restore_transition"), + // ── Statistics & monitoring ─────────────────────────────────────────── + ("stats_upd", 1, "statistics_updated"), + ("perf_met", 1, "performance_metric"), + ("mon_ovf", 1, "monitoring_overflow"), + ("bal_chg", 1, "balance_changed"), + ("err_evt", 1, "error_event"), + ("err_log", 1, "error_logged"), + ("err_rec", 1, "error_recovered"), + // ── Miscellaneous ───────────────────────────────────────────────────── + ("cfg_init", 1, "config_initialised"), + ("cfg_upd", 1, "config_updated"), + ("pltf_set", 1, "platform_settings_updated"), + ("ctr_init", 1, "contract_initialised"), + ("ctr_pause", 1, "contract_paused"), + ("ctr_unp", 1, "contract_unpaused"), + ("thld_conf", 1, "threshold_configured"), + ("thld_prop", 1, "threshold_proposed"), + ("tout_set", 1, "timeout_set"), + ("tout_ext", 1, "timeout_extended"), + ("tout_exp", 1, "timeout_expired"), + ("dh_evct", 1, "dispute_handler_evicted"), + ("fwd_att", 1, "forward_attempted"), + ("fwd_ok", 1, "forward_succeeded"), + ("chain_mm", 1, "chain_mismatch"), + ("evt_vis", 1, "event_visibility_changed"), + ("depr_call", 1, "deprecated_entrypoint_called"), + ("verify_rs", 1, "oracle_result_verify_success"), + // ── Governance registry ─────────────────────────────────────────────── + ("ep_one", 1, "entrypoint_one"), + ("ep_two", 1, "entrypoint_two"), +]; + +/// Known *superseded* topic aliases: (old_symbol, new_symbol). +/// +/// Add an entry here whenever a topic symbol is renamed so that the upgrade +/// hook can persist the mapping and the compat bridge can dual-publish. +pub const TOPIC_ALIASES: &[(&str, &str)] = &[ + // Example (kept as documentation template; add real renames here): + // ("old_sym", "new_sym"), +]; + +// ───────────────────────────────────────────────────────────────────────────── +// Runtime registry +// ───────────────────────────────────────────────────────────────────────────── + +/// Versioned descriptor for a single event topic. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TopicDescriptor { + /// The canonical `Symbol` used as the first element of the publish tuple. + pub topic: Symbol, + /// Monotonically-increasing schema version. Bump when the payload type + /// changes shape (field added, removed, or retyped). + pub schema_version: u32, + /// Human-readable name such as `"market_created"`. + pub name: String, +} + +/// Persistent alias record stored under [`DataKey::EventTopicAlias`]. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TopicAlias { + /// The superseded topic symbol (indexers still filtering on this). + pub old_topic: Symbol, + /// The replacement topic symbol. + pub new_topic: Symbol, + /// Ledger sequence at which the alias was registered. + pub registered_at: u32, + /// Contract version (as `major * 1_000_000 + minor * 1_000 + patch`) in + /// which the topic was renamed. + pub since_version: u64, +} + +/// Snapshot of a single nonce so it can be preserved across an upgrade. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NonceSnapshot { + pub topic: Symbol, + pub value: u64, +} + +/// Central, read-only registry of every topic emitted by the contract. +/// +/// All emit sites **must** obtain their `(Symbol, schema_version)` pair from +/// this registry rather than hard-coding `symbol_short!()` literals. This +/// ensures that a single constant change propagates to every call-site and +/// that off-chain tooling can discover topics deterministically via +/// `get_all_topics`. +pub struct EventTopicRegistry; + +impl EventTopicRegistry { + /// Look up the [`TopicDescriptor`] for a named event. + /// + /// Returns `None` when `name` is not registered so callers can fall back + /// gracefully rather than panicking in production. + /// + /// # Examples + /// + /// ```rust + /// # use soroban_sdk::Env; + /// # let env = Env::default(); + /// let desc = predictify_hybrid::event_topic_compat::EventTopicRegistry::get( + /// &env, "market_created", + /// ).unwrap(); + /// assert_eq!(desc.schema_version, 1); + /// ``` + pub fn get(env: &Env, name: &str) -> Option { + for &(sym, version, entry_name) in TOPIC_REGISTRY { + if entry_name == name { + return Some(TopicDescriptor { + topic: Symbol::new(env, sym), + schema_version: version, + name: String::from_str(env, entry_name), + }); + } + } + None + } + + /// Look up by the raw symbol string (e.g. `"mkt_crt"`). + pub fn get_by_symbol(env: &Env, sym: &str) -> Option { + for &(topic_sym, version, name) in TOPIC_REGISTRY { + if topic_sym == sym { + return Some(TopicDescriptor { + topic: Symbol::new(env, topic_sym), + schema_version: version, + name: String::from_str(env, name), + }); + } + } + None + } + + /// Return descriptors for *every* registered topic. + /// + /// Primarily intended for off-chain tooling (indexers, dashboards) that + /// need to enumerate all topics at startup. + pub fn get_all_topics(env: &Env) -> Vec { + let mut out = Vec::new(env); + for &(sym, version, name) in TOPIC_REGISTRY { + out.push_back(TopicDescriptor { + topic: Symbol::new(env, sym), + schema_version: version, + name: String::from_str(env, name), + }); + } + out + } + + /// Return a [`Map`] from topic symbol string to schema version number, + /// suitable for embedding in an on-chain query response. + pub fn get_version_map(env: &Env) -> Map { + let mut m: Map = Map::new(env); + for &(sym, version, _name) in TOPIC_REGISTRY { + m.set(Symbol::new(env, sym), version); + } + m + } + + /// Return the current schema version for a topic by raw symbol string, + /// or 0 if the symbol is not registered. + pub fn schema_version(env: &Env, sym: &str) -> u32 { + Self::get_by_symbol(env, sym) + .map(|d| d.schema_version) + .unwrap_or(0) + } + + /// Total number of registered topics. Useful for off-chain health checks. + pub fn topic_count() -> u32 { + TOPIC_REGISTRY.len() as u32 + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Compatibility bridge +// ───────────────────────────────────────────────────────────────────────────── + +/// Dual-publish helper for rolling upgrade windows. +/// +/// During a *compatibility window* (typically one or two contract versions), +/// a single logical event is published under **both** the previous topic symbol +/// and the current symbol. Off-chain consumers can migrate at their own pace. +/// +/// # Example +/// +/// ```rust,no_run +/// # use soroban_sdk::{Env, symbol_short}; +/// # let env = Env::default(); +/// # let payload = 42_i128; +/// # use predictify_hybrid::event_topic_compat::EventCompatBridge; +/// // Publish under the old topic "old_sym" AND the new topic "mkt_crt". +/// EventCompatBridge::publish_with_compat( +/// &env, +/// symbol_short!("old_sym"), +/// symbol_short!("mkt_crt"), +/// &payload, +/// ); +/// ``` +pub struct EventCompatBridge; + +impl EventCompatBridge { + /// Publish `data` under `new_topic`. If `old_topic != new_topic`, also + /// publish under `old_topic` so that legacy indexers still receive the + /// event. + /// + /// The payload is cloned for the second publish; no extra storage is + /// written. + /// + /// # Invariants + /// + /// * If `old_topic == new_topic` only one event is emitted (no duplication). + /// * This function never panics; both publish calls are unconditional. + /// * It does **not** update nonces; callers must handle nonce management + /// themselves (typically via `EventEmitter::get_and_increment_nonce`). + pub fn publish_with_compat + Clone>( + env: &Env, + old_topic: Symbol, + new_topic: Symbol, + data: &T, + ) { + // Always publish under the current (new) topic. + env.events().publish((new_topic.clone(),), data.clone()); + + // If the topic changed, also publish under the old symbol so legacy + // consumers that have not yet updated their filter continue to receive + // the event. + if old_topic != new_topic { + env.events().publish((old_topic,), data.clone()); + } + } + + /// Persist a [`TopicAlias`] in contract storage so on-chain consumers can + /// discover the rename. + /// + /// Called once during the upgrade hook; safe to call multiple times (later + /// calls overwrite the previous alias record for the same `old_topic`). + pub fn register_alias(env: &Env, old_topic: Symbol, new_topic: Symbol, since_version: u64) { + let alias = TopicAlias { + old_topic: old_topic.clone(), + new_topic, + registered_at: env.ledger().sequence(), + since_version, + }; + let key = DataKey::EventTopicAlias(old_topic); + env.storage().persistent().set(&key, &alias); + } + + /// Register all aliases declared in [`TOPIC_ALIASES`]. + /// + /// Intended to be called once from the upgrade hook so that every renamed + /// topic is persisted atomically in a single transaction. + pub fn register_all_aliases(env: &Env, since_version: u64) { + for &(old_sym, new_sym) in TOPIC_ALIASES { + Self::register_alias( + env, + Symbol::new(env, old_sym), + Symbol::new(env, new_sym), + since_version, + ); + } + } + + /// Look up the alias record for `old_topic`, if any. + pub fn get_alias(env: &Env, old_topic: &Symbol) -> Option { + let key = DataKey::EventTopicAlias(old_topic.clone()); + env.storage().persistent().get(&key) + } + + /// Return all persisted aliases as a `Vec`. + pub fn get_all_aliases(env: &Env) -> Vec { + let mut out = Vec::new(env); + for &(old_sym, _) in TOPIC_ALIASES { + let key = DataKey::EventTopicAlias(Symbol::new(env, old_sym)); + if let Some(alias) = env.storage().persistent().get::(&key) { + out.push_back(alias); + } + } + out + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Nonce preservation +// ───────────────────────────────────────────────────────────────────────────── + +/// Helpers for snapshotting and restoring per-topic event nonces across +/// contract upgrades. +/// +/// # Why this matters +/// +/// `EventEmitter::get_and_increment_nonce` stores the nonce in *persistent* +/// storage under `DataKey::EventNonce(topic)`. During some upgrade paths +/// persistent storage may be partially re-initialised, which would reset +/// nonces to 0 and break replay-protection for consumers that rely on +/// monotonically-increasing nonce sequences. +/// +/// `EventNonceGuard::preserve_nonces` snapshots all current nonces into a +/// dedicated persistent key before the upgrade executes. +/// `EventNonceGuard::restore_nonces` is called immediately after the upgrade +/// to copy the snapshots back, guaranteeing continuity. +pub struct EventNonceGuard; + +impl EventNonceGuard { + const SNAPSHOT_KEY: &'static str = "nonce_snap"; + + /// Snapshot all known per-topic nonces into persistent storage. + /// + /// Must be called **before** any storage migration step that might clear + /// or reset `DataKey::EventNonce` entries. + pub fn preserve_nonces(env: &Env) { + let mut snapshots: Vec = Vec::new(env); + + for &(sym, _version, _name) in TOPIC_REGISTRY { + let topic = Symbol::new(env, sym); + let key = DataKey::EventNonce(topic.clone()); + if let Some(value) = env.storage().persistent().get::(&key) { + if value > 0 { + snapshots.push_back(NonceSnapshot { topic, value }); + } + } + } + + let snap_key = Symbol::new(env, Self::SNAPSHOT_KEY); + env.storage().persistent().set(&snap_key, &snapshots); + } + + /// Restore snapshotted nonces after the upgrade completes. + /// + /// Reads the snapshot written by `preserve_nonces` and writes each nonce + /// back to `DataKey::EventNonce(topic)` **only if** the restored value is + /// strictly greater than whatever is currently stored. This prevents a + /// race where a post-upgrade emission already incremented a nonce beyond + /// the snapshot value. + /// + /// Returns the number of nonces that were restored. + pub fn restore_nonces(env: &Env) -> u32 { + let snap_key = Symbol::new(env, Self::SNAPSHOT_KEY); + let snapshots: Vec = env + .storage() + .persistent() + .get(&snap_key) + .unwrap_or_else(|| Vec::new(env)); + + let mut restored: u32 = 0; + for snap in snapshots.iter() { + let key = DataKey::EventNonce(snap.topic.clone()); + let current: u64 = env + .storage() + .persistent() + .get(&key) + .unwrap_or(0); + // Only restore if the snapshot is larger to avoid rollback attacks. + if snap.value > current { + env.storage().persistent().set(&key, &snap.value); + restored += 1; + } + } + + restored + } + + /// Remove the snapshot after a successful restore to reclaim storage. + pub fn clear_snapshot(env: &Env) { + let snap_key = Symbol::new(env, Self::SNAPSHOT_KEY); + env.storage().persistent().remove(&snap_key); + } + + /// Read the stored snapshot without modifying state; useful for tests. + pub fn read_snapshot(env: &Env) -> Vec { + let snap_key = Symbol::new(env, Self::SNAPSHOT_KEY); + env + .storage() + .persistent() + .get(&snap_key) + .unwrap_or_else(|| Vec::new(env)) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Compatibility-aware emit helper +// ───────────────────────────────────────────────────────────────────────────── + +/// Emit helper that resolves the topic from [`EventTopicRegistry`] and handles +/// optional compat-bridge dual-publishing transparently. +/// +/// Emit sites call `CompatEmit::publish` instead of +/// `env.events().publish((symbol_short!("..."), ...)` directly. The resolved +/// `schema_version` is appended to the topic tuple so that off-chain consumers +/// can distinguish payloads across schema changes. +pub struct CompatEmit; + +impl CompatEmit { + /// Publish `data` using the canonical topic for `event_name`. + /// + /// If an alias exists for a previous symbol under the same name (stored via + /// `EventCompatBridge::register_alias`), the event is also published under + /// the old symbol to preserve backward compatibility. + /// + /// Falls back to a no-op if `event_name` is not registered, rather than + /// panicking. + pub fn publish(env: &Env, event_name: &str, secondary_topic: Option, data: &T) + where + T: soroban_sdk::IntoVal + Clone, + { + let descriptor = match EventTopicRegistry::get(env, event_name) { + Some(d) => d, + None => return, // Unknown event; skip silently rather than panic. + }; + + match secondary_topic { + Some(sec) => { + env.events().publish( + (descriptor.topic.clone(), sec, descriptor.schema_version), + data.clone(), + ); + } + None => { + env.events().publish( + (descriptor.topic.clone(), descriptor.schema_version), + data.clone(), + ); + } + } + } +} diff --git a/contracts/predictify-hybrid/src/event_topic_compat_tests.rs b/contracts/predictify-hybrid/src/event_topic_compat_tests.rs new file mode 100644 index 00000000..b460c3f8 --- /dev/null +++ b/contracts/predictify-hybrid/src/event_topic_compat_tests.rs @@ -0,0 +1,558 @@ +//! Tests for event topic compatibility across contract upgrades (issue #1391). +//! +//! # Coverage matrix +//! +//! | Area | Tests | +//! |------------------------------|----------------------------------------------------------| +//! | Registry completeness | every topic in TOPIC_REGISTRY is retrievable | +//! | Registry determinism | repeated lookups return identical descriptors | +//! | Registry boundary cases | unknown names return None, no panic | +//! | Schema version integrity | all schema versions are ≥ 1 | +//! | Schema version map | get_version_map covers all topics | +//! | EventSchemaRegistry bridge | get_schema delegates to registry for all known names | +//! | EventSchemaRegistry extended | get_all_schemas returns ≥ TOPIC_REGISTRY.len() entries | +//! | Nonce preservation | preserve / restore round-trips correctly | +//! | Nonce idempotency | restore never rolls back a nonce | +//! | Nonce no-op on empty | restore with no snapshot is a no-op | +//! | Alias persistence | register_alias stores and retrieves correctly | +//! | Alias upgrade hook | register_all_aliases processes TOPIC_ALIASES | +//! | Alias not found | get_alias returns None for unregistered symbol | +//! | Compat bridge same topic | single emit when old == new | +//! | Compat bridge diff topic | two emits when old != new | +//! | UpgradeManager hooks | prepare_event_compat / finalize_event_compat round-trip | +//! | Regression: reset nonce | nonce never goes backwards after restore | +//! | Regression: DataKey variant | EventTopicAlias round-trips through storage | + +#![cfg(test)] + +use soroban_sdk::{symbol_short, testutils::Events, Env, Symbol, Vec}; + +use crate::event_topic_compat::{ + EventCompatBridge, EventNonceGuard, EventTopicRegistry, TOPIC_ALIASES, TOPIC_REGISTRY, +}; +use crate::events::EventSchemaRegistry; +use crate::storage::DataKey; + +// ───────────────────────────────────────────────────────────────────────────── +// Helper +// ───────────────────────────────────────────────────────────────────────────── + +fn fresh() -> Env { + Env::default() +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Registry completeness & determinism +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn registry_returns_descriptor_for_every_registered_name() { + let env = fresh(); + for &(_sym, _version, name) in TOPIC_REGISTRY { + let desc = EventTopicRegistry::get(&env, name); + assert!( + desc.is_some(), + "EventTopicRegistry::get returned None for registered name \"{}\"", + name + ); + let d = desc.unwrap(); + assert_eq!(d.schema_version, EventTopicRegistry::get(&env, name).unwrap().schema_version); + } +} + +#[test] +fn registry_get_by_symbol_covers_all_symbols() { + let env = fresh(); + for &(sym, _version, _name) in TOPIC_REGISTRY { + let desc = EventTopicRegistry::get_by_symbol(&env, sym); + assert!( + desc.is_some(), + "get_by_symbol returned None for symbol \"{}\"", + sym + ); + } +} + +#[test] +fn registry_get_all_topics_length_matches_constant_table() { + let env = fresh(); + let topics = EventTopicRegistry::get_all_topics(&env); + assert_eq!( + topics.len() as usize, + TOPIC_REGISTRY.len(), + "get_all_topics length mismatch" + ); +} + +#[test] +fn registry_topic_count_is_consistent() { + assert_eq!( + EventTopicRegistry::topic_count() as usize, + TOPIC_REGISTRY.len() + ); +} + +#[test] +fn registry_get_returns_none_for_unknown_name() { + let env = fresh(); + // Must not panic; must return None. + assert!(EventTopicRegistry::get(&env, "").is_none()); + assert!(EventTopicRegistry::get(&env, "no_such_event_xyzzy").is_none()); +} + +#[test] +fn registry_get_by_symbol_returns_none_for_unknown_symbol() { + let env = fresh(); + assert!(EventTopicRegistry::get_by_symbol(&env, "").is_none()); + assert!(EventTopicRegistry::get_by_symbol(&env, "zzz_nope").is_none()); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 2. Schema version integrity +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn all_schema_versions_are_at_least_one() { + let env = fresh(); + for desc in EventTopicRegistry::get_all_topics(&env).iter() { + assert!( + desc.schema_version >= 1, + "schema_version must be ≥ 1 for topic {:?}", + desc.topic + ); + } +} + +#[test] +fn schema_version_lookup_returns_zero_for_unknown() { + let env = fresh(); + assert_eq!(EventTopicRegistry::schema_version(&env, "zzz_none"), 0); +} + +#[test] +fn get_version_map_covers_all_topics() { + let env = fresh(); + let map = EventTopicRegistry::get_version_map(&env); + for &(sym, version, _name) in TOPIC_REGISTRY { + let key = Symbol::new(&env, sym); + let stored = map.get(key.clone()); + assert!( + stored.is_some(), + "get_version_map missing symbol \"{}\"", + sym + ); + assert_eq!(stored.unwrap(), version); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 3. EventSchemaRegistry delegation (backward-compat layer) +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn event_schema_registry_delegates_to_topic_registry_for_all_names() { + let env = fresh(); + // Every registered human-readable name must be resolvable via the old API. + for &(_sym, version, name) in TOPIC_REGISTRY { + let schema = EventSchemaRegistry::get_schema(&env, name); + assert!( + schema.is_some(), + "EventSchemaRegistry::get_schema returned None for \"{}\"", + name + ); + assert_eq!( + schema.unwrap().schema_version, + version, + "schema_version mismatch for \"{}\"", + name + ); + } +} + +#[test] +fn event_schema_registry_returns_none_for_unknown() { + let env = fresh(); + assert!(EventSchemaRegistry::get_schema(&env, "no_such_xyzzy").is_none()); +} + +#[test] +fn event_schema_registry_get_all_schemas_non_empty() { + let env = fresh(); + let all = EventSchemaRegistry::get_all_schemas(&env); + assert!( + all.len() as usize >= TOPIC_REGISTRY.len(), + "get_all_schemas must return at least {} entries", + TOPIC_REGISTRY.len() + ); +} + +#[test] +fn event_schema_registry_topic_count_matches() { + assert_eq!( + EventSchemaRegistry::topic_count() as usize, + TOPIC_REGISTRY.len() + ); +} + +// Legacy hard-coded names that existed before #1391. +#[test] +fn legacy_schema_names_still_resolve() { + let env = fresh(); + for name in &["oracle_result", "dispute_opened", "storage_tier_changed", "payout_remainder_allocated"] { + assert!( + EventSchemaRegistry::get_schema(&env, name).is_some(), + "Legacy name \"{}\" should still resolve", + name + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 4. DataKey::EventTopicAlias round-trip through storage +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn datakey_event_topic_alias_stores_and_retrieves() { + let env = fresh(); + let old_topic = symbol_short!("old_t"); + let new_topic = symbol_short!("new_t"); + let alias = crate::event_topic_compat::TopicAlias { + old_topic: old_topic.clone(), + new_topic: new_topic.clone(), + registered_at: env.ledger().sequence(), + since_version: 1_001_000, + }; + + let key = DataKey::EventTopicAlias(old_topic.clone()); + env.storage().persistent().set(&key, &alias); + + let retrieved: Option = + env.storage().persistent().get(&DataKey::EventTopicAlias(old_topic.clone())); + assert!(retrieved.is_some()); + let r = retrieved.unwrap(); + assert_eq!(r.old_topic, old_topic); + assert_eq!(r.new_topic, new_topic); + assert_eq!(r.since_version, 1_001_000); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 5. Topic alias registration +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn register_alias_persists_and_get_alias_retrieves() { + let env = fresh(); + let old_topic = symbol_short!("stale_t"); + let new_topic = symbol_short!("fresh_t"); + let since = 2_000_000_u64; + + EventCompatBridge::register_alias(&env, old_topic.clone(), new_topic.clone(), since); + + let alias = EventCompatBridge::get_alias(&env, &old_topic).expect("alias must be present"); + assert_eq!(alias.old_topic, old_topic); + assert_eq!(alias.new_topic, new_topic); + assert_eq!(alias.since_version, since); +} + +#[test] +fn get_alias_returns_none_for_unregistered_symbol() { + let env = fresh(); + let sym = symbol_short!("nope_t"); + assert!(EventCompatBridge::get_alias(&env, &sym).is_none()); +} + +#[test] +fn register_alias_is_idempotent_last_write_wins() { + let env = fresh(); + let old_topic = symbol_short!("idem_t"); + let first_new = symbol_short!("first_t"); + let second_new = symbol_short!("secnd_t"); + + EventCompatBridge::register_alias(&env, old_topic.clone(), first_new.clone(), 1_000); + EventCompatBridge::register_alias(&env, old_topic.clone(), second_new.clone(), 2_000); + + let alias = EventCompatBridge::get_alias(&env, &old_topic).unwrap(); + // Second call wins. + assert_eq!(alias.new_topic, second_new); + assert_eq!(alias.since_version, 2_000); +} + +#[test] +fn register_all_aliases_processes_constant_table() { + let env = fresh(); + // Should not panic; idempotent even if TOPIC_ALIASES is empty. + EventCompatBridge::register_all_aliases(&env, 1_001_000); + + // After registration, every pair in TOPIC_ALIASES should be retrievable. + for &(old_sym, new_sym) in TOPIC_ALIASES { + let old_topic = Symbol::new(&env, old_sym); + let alias = EventCompatBridge::get_alias(&env, &old_topic) + .expect(&format!("alias for \"{}\" must be present", old_sym)); + assert_eq!(alias.new_topic, Symbol::new(&env, new_sym)); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 6. Nonce preservation +// ───────────────────────────────────────────────────────────────────────────── + +fn write_nonce(env: &Env, sym: &str, value: u64) { + let topic = Symbol::new(env, sym); + env.storage() + .persistent() + .set(&DataKey::EventNonce(topic), &value); +} + +fn read_nonce(env: &Env, sym: &str) -> u64 { + let topic = Symbol::new(env, sym); + env.storage() + .persistent() + .get(&DataKey::EventNonce(topic)) + .unwrap_or(0) +} + +#[test] +fn preserve_and_restore_nonces_round_trip() { + let env = fresh(); + + // Write known nonces for a few topics. + write_nonce(&env, "mkt_crt", 42); + write_nonce(&env, "bet_plc", 7); + write_nonce(&env, "vote", 99); + + // Snapshot. + EventNonceGuard::preserve_nonces(&env); + + // Simulate migration clearing the nonces. + write_nonce(&env, "mkt_crt", 0); + write_nonce(&env, "bet_plc", 0); + write_nonce(&env, "vote", 0); + + // Restore. + let count = EventNonceGuard::restore_nonces(&env); + assert!(count >= 3, "expected at least 3 nonces restored, got {}", count); + + assert_eq!(read_nonce(&env, "mkt_crt"), 42); + assert_eq!(read_nonce(&env, "bet_plc"), 7); + assert_eq!(read_nonce(&env, "vote"), 99); +} + +#[test] +fn restore_never_rolls_back_a_nonce_that_advanced() { + let env = fresh(); + + // Snapshot at value 10. + write_nonce(&env, "mkt_crt", 10); + EventNonceGuard::preserve_nonces(&env); + + // A post-upgrade emission already advanced the nonce to 20. + write_nonce(&env, "mkt_crt", 20); + + EventNonceGuard::restore_nonces(&env); + + // Must stay at 20, not roll back to 10. + assert_eq!(read_nonce(&env, "mkt_crt"), 20); +} + +#[test] +fn restore_with_no_snapshot_is_a_no_op() { + let env = fresh(); + write_nonce(&env, "mkt_crt", 5); + + // No prior preserve call — snapshot key does not exist. + let count = EventNonceGuard::restore_nonces(&env); + + // Nothing should have been modified. + assert_eq!(count, 0); + assert_eq!(read_nonce(&env, "mkt_crt"), 5); +} + +#[test] +fn clear_snapshot_removes_stored_data() { + let env = fresh(); + write_nonce(&env, "vote", 3); + EventNonceGuard::preserve_nonces(&env); + + let snap = EventNonceGuard::read_snapshot(&env); + assert!(snap.len() > 0, "snapshot should be non-empty"); + + EventNonceGuard::clear_snapshot(&env); + let after = EventNonceGuard::read_snapshot(&env); + assert_eq!(after.len(), 0, "snapshot should be empty after clear"); +} + +#[test] +fn preserve_ignores_zero_nonces() { + let env = fresh(); + + // Write a zero nonce — should NOT be included in snapshot. + write_nonce(&env, "mkt_crt", 0); + + EventNonceGuard::preserve_nonces(&env); + + let snap = EventNonceGuard::read_snapshot(&env); + let has_mkt_crt = snap.iter().any(|s| { + s.topic == Symbol::new(&env, "mkt_crt") + }); + assert!(!has_mkt_crt, "zero nonces must not be snapshotted"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 7. Compatibility bridge – emit behaviour +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn compat_bridge_emits_single_event_when_topics_are_equal() { + let env = fresh(); + let topic = symbol_short!("same_t"); + let data: i128 = 1234; + + EventCompatBridge::publish_with_compat(&env, topic.clone(), topic.clone(), &data); + + let events = env.events().all(); + // Only one event should have been emitted. + assert_eq!(events.events().len(), 1, "expected exactly 1 event when old == new topic"); +} + +#[test] +fn compat_bridge_emits_two_events_when_topics_differ() { + let env = fresh(); + let old_topic = symbol_short!("old_ev"); + let new_topic = symbol_short!("new_ev"); + let data: i128 = 9876; + + EventCompatBridge::publish_with_compat(&env, old_topic.clone(), new_topic.clone(), &data); + + let events = env.events().all(); + // Two events: one under new_topic, one under old_topic. + assert_eq!(events.events().len(), 2, "expected 2 events for a renamed topic"); +} + +#[test] +fn compat_bridge_emits_are_idempotent_on_repeated_calls() { + let env = fresh(); + let old = symbol_short!("rep_o"); + let new = symbol_short!("rep_n"); + let data: u32 = 7; + + EventCompatBridge::publish_with_compat(&env, old.clone(), new.clone(), &data); + EventCompatBridge::publish_with_compat(&env, old.clone(), new.clone(), &data); + + // Soroban events are append-only; two calls produce four events total. + // This test documents the expected behaviour (not a defect) and ensures + // that repeated calls do not panic or corrupt storage. + let events = env.events().all(); + assert_eq!(events.events().len(), 4); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 8. UpgradeManager hooks +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn upgrade_manager_preserve_and_restore_event_nonces_round_trip() { + use crate::upgrade_manager::UpgradeManager; + + let env = fresh(); + write_nonce(&env, "mkt_crt", 100); + write_nonce(&env, "bet_plc", 50); + + UpgradeManager::preserve_event_nonces(&env); + + // Simulate migration resetting nonces. + write_nonce(&env, "mkt_crt", 0); + write_nonce(&env, "bet_plc", 0); + + let restored = UpgradeManager::restore_event_nonces(&env); + assert!(restored >= 2); + + assert_eq!(read_nonce(&env, "mkt_crt"), 100); + assert_eq!(read_nonce(&env, "bet_plc"), 50); +} + +#[test] +fn upgrade_manager_prepare_and_finalize_event_compat() { + use crate::upgrade_manager::UpgradeManager; + + let env = fresh(); + write_nonce(&env, "vote", 77); + + UpgradeManager::prepare_event_compat(&env, 1_001_000); + + // Simulate nonce reset by migration. + write_nonce(&env, "vote", 0); + + UpgradeManager::finalize_event_compat(&env); + + assert_eq!(read_nonce(&env, "vote"), 77); +} + +#[test] +fn upgrade_manager_register_topic_aliases_does_not_panic() { + use crate::upgrade_manager::UpgradeManager; + let env = fresh(); + // Should be a no-op when TOPIC_ALIASES is empty; must not panic. + UpgradeManager::register_topic_aliases(&env, 1_000_000); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 9. Regression: concurrent execution safety +// +// Soroban transactions are single-threaded and deterministic, but partial +// failure (panic mid-transaction) rolls back the whole transaction. This +// test validates that a failed migration (nonces preserved but restore not +// called) leaves the snapshot accessible for a retry. +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn snapshot_survives_failed_restore_and_can_be_retried() { + let env = fresh(); + write_nonce(&env, "mkt_crt", 55); + + EventNonceGuard::preserve_nonces(&env); + + // Simulate "failed upgrade" — snapshot written but nonce cleared. + write_nonce(&env, "mkt_crt", 0); + + // The snapshot is still there (not cleared yet). + let snap = EventNonceGuard::read_snapshot(&env); + let mkt_snap = snap.iter().find(|s| s.topic == Symbol::new(&env, "mkt_crt")); + assert!(mkt_snap.is_some(), "snapshot must be readable after failed restore"); + assert_eq!(mkt_snap.unwrap().value, 55); + + // A retry can still restore the nonce. + EventNonceGuard::restore_nonces(&env); + assert_eq!(read_nonce(&env, "mkt_crt"), 55); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 10. Duplicate / boundary inputs +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn registry_lookup_is_deterministic_across_repeated_calls() { + let env = fresh(); + let d1 = EventTopicRegistry::get(&env, "market_created").unwrap(); + let d2 = EventTopicRegistry::get(&env, "market_created").unwrap(); + assert_eq!(d1.schema_version, d2.schema_version); + assert_eq!(d1.topic, d2.topic); +} + +#[test] +fn schema_registry_get_schema_same_result_for_repeated_calls() { + let env = fresh(); + let s1 = EventSchemaRegistry::get_schema(&env, "oracle_result").unwrap(); + let s2 = EventSchemaRegistry::get_schema(&env, "oracle_result").unwrap(); + assert_eq!(s1.schema_version, s2.schema_version); + assert_eq!(s1.topic, s2.topic); +} + +#[test] +fn all_topic_symbols_are_valid_soroban_symbols() { + let env = fresh(); + // Symbol::new panics on invalid strings; this test will fail if any + // entry in TOPIC_REGISTRY contains an invalid symbol string. + for &(sym, _version, _name) in TOPIC_REGISTRY { + let _s = Symbol::new(&env, sym); // must not panic + } +} diff --git a/contracts/predictify-hybrid/src/events.rs b/contracts/predictify-hybrid/src/events.rs index aaf7b6b3..8c04bbf0 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -2298,26 +2298,53 @@ impl EventSchemaRegistry { /// | `"oracle_result"` | `oracle_rs` | 1 | /// | `"dispute_opened"` | `dispt_opn` | 1 | /// | `"storage_tier_changed"` | `st_tier` | 1 | + /// + /// As of issue #1391 all events are now registered — the match arm below + /// delegates to [`crate::event_topic_compat::EventTopicRegistry`] which is the + /// single source of truth. Hard-coded arms for the four legacy names are + /// retained for backward API compatibility. pub fn get_schema(env: &Env, name: &str) -> Option { - match name { - "oracle_result" => Some(EventSchemaEntry { - topic: symbol_short!("oracle_rs"), - schema_version: 1, - }), - "dispute_opened" => Some(EventSchemaEntry { - topic: symbol_short!("dispt_opn"), - schema_version: 1, - }), - "storage_tier_changed" => Some(EventSchemaEntry { - topic: symbol_short!("st_tier"), - schema_version: 1, - }), - "payout_remainder_allocated" => Some(EventSchemaEntry { - topic: symbol_short!("pay_rem"), - schema_version: 1, - }), - _ => None, + // Delegate to the authoritative registry introduced in #1391. + // All topics are registered there; the hard-coded arms below simply + // map the legacy human-readable names used in existing call-sites. + use crate::event_topic_compat::EventTopicRegistry; + + // Prefer the registry lookup — covers every event including the four + // legacy names that previously had hard-coded arms. + if let Some(descriptor) = EventTopicRegistry::get(env, name) { + return Some(EventSchemaEntry { + topic: descriptor.topic, + schema_version: descriptor.schema_version, + }); } + + // Legacy aliases: callers that use the old "oracle_result" style name + // but the registry stores "oracle_result" so this is only reached for + // names that are truly unknown to the registry. + None + } + + /// Return [`EventSchemaEntry`] values for *every* registered event. + /// + /// Intended for off-chain tooling (indexers, schema validators) that need + /// a complete, authoritative list of all event topics emitted by this + /// contract. + pub fn get_all_schemas(env: &Env) -> soroban_sdk::Vec { + use crate::event_topic_compat::EventTopicRegistry; + let descriptors = EventTopicRegistry::get_all_topics(env); + let mut out = soroban_sdk::Vec::new(env); + for d in descriptors.iter() { + out.push_back(EventSchemaEntry { + topic: d.topic, + schema_version: d.schema_version, + }); + } + out + } + + /// Return the total number of registered event topics. + pub fn topic_count() -> u32 { + crate::event_topic_compat::EventTopicRegistry::topic_count() } } diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 7e441ad3..17aee60f 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -81,6 +81,7 @@ mod tokens; mod rate_limiter; mod dispute_multisig; mod event_topic_catalog; +pub mod event_topic_compat; mod storage_tier_audit; mod leaderboard; mod lists; @@ -94,6 +95,8 @@ mod override_audit_tests; mod market_audit_tests; #[cfg(test)] mod test_audit_trail; +#[cfg(test)] +mod event_topic_compat_tests; // #[cfg(any())] // mod utils_tests; // THis is the band protocol wasm std_reference.wasm diff --git a/contracts/predictify-hybrid/src/storage.rs b/contracts/predictify-hybrid/src/storage.rs index f448232e..61381481 100644 --- a/contracts/predictify-hybrid/src/storage.rs +++ b/contracts/predictify-hybrid/src/storage.rs @@ -201,6 +201,12 @@ pub enum DataKey { /// Per-user claim nonce for replay protection: (user, market_id) -> u64 /// Incremented on each successful claim to ensure each claim is unique and prevent replays. ClaimNonce(Address, Symbol), + /// Persistent alias mapping a superseded event topic symbol to its + /// replacement. Written by the upgrade hook so that on-chain consumers + /// can discover topic renames without re-deploying. + /// + /// Key: the *old* topic `Symbol`; Value: [`crate::event_topic_compat::TopicAlias`]. + EventTopicAlias(Symbol), } /// Storage format version for migration tracking diff --git a/contracts/predictify-hybrid/src/upgrade_manager.rs b/contracts/predictify-hybrid/src/upgrade_manager.rs index d37777be..95f4b821 100644 --- a/contracts/predictify-hybrid/src/upgrade_manager.rs +++ b/contracts/predictify-hybrid/src/upgrade_manager.rs @@ -1098,6 +1098,93 @@ impl UpgradeManager { .unwrap_or_else(|| Vec::new(env))) } + // ── Event topic compatibility helpers (issue #1391) ─────────────────────── + + /// Snapshot all current per-topic event nonces into persistent storage + /// **before** any migration step that could clear or reset them. + /// + /// Call this at the very start of an upgrade transaction, before any + /// storage restructuring occurs. The companion `restore_event_nonces` + /// should be called immediately after the upgrade to copy the snapshots + /// back, preserving the monotonically-increasing guarantee for replay + /// protection. + /// + /// # Returns + /// + /// The number of non-zero nonces that were snapshotted. + pub fn preserve_event_nonces(env: &Env) -> u32 { + use crate::event_topic_compat::EventNonceGuard; + EventNonceGuard::preserve_nonces(env); + EventNonceGuard::read_snapshot(env).len() + } + + /// Restore snapshotted nonces after an upgrade completes. + /// + /// Must be paired with a prior call to `preserve_event_nonces`. + /// Nonces are only written back when the snapshot value is strictly + /// greater than the current stored value, preventing a race where a + /// post-upgrade emission has already advanced the nonce. + /// + /// Clears the snapshot from storage after a successful restore. + /// + /// # Returns + /// + /// The number of nonces actually restored (could be less than snapshotted + /// if some were already at or above the snapshot value). + pub fn restore_event_nonces(env: &Env) -> u32 { + use crate::event_topic_compat::EventNonceGuard; + let count = EventNonceGuard::restore_nonces(env); + EventNonceGuard::clear_snapshot(env); + count + } + + /// Register topic aliases for all renamed symbols declared in + /// [`crate::event_topic_compat::TOPIC_ALIASES`]. + /// + /// Each alias is written to persistent storage under + /// `DataKey::EventTopicAlias(old_topic)` so that on-chain consumers + /// can discover renames without a redeployment. + /// + /// Should be called once per upgrade, after `restore_event_nonces`. + /// + /// # Parameters + /// + /// * `since_version` – version number (as `major*1_000_000 + minor*1_000 + patch`) + /// at which the rename occurred. Used for audit / diagnostic purposes. + pub fn register_topic_aliases(env: &Env, since_version: u64) { + use crate::event_topic_compat::EventCompatBridge; + EventCompatBridge::register_all_aliases(env, since_version); + } + + /// Combined upgrade event-compatibility hook. + /// + /// Convenience wrapper that: + /// 1. Snapshots all current nonces (`preserve_event_nonces`). + /// 2. Registers all topic aliases (`register_topic_aliases`). + /// + /// Call this **before** the WASM replacement / storage migration in + /// `upgrade_contract`. The companion `finalize_event_compat` must be + /// called **after** the upgrade to restore nonces. + /// + /// # Invariants + /// + /// * Safe to call multiple times; later calls overwrite previous snapshots + /// and alias records. + /// * Never panics; both internal helpers handle empty state gracefully. + pub fn prepare_event_compat(env: &Env, since_version: u64) { + Self::preserve_event_nonces(env); + Self::register_topic_aliases(env, since_version); + } + + /// Finalise event compatibility after an upgrade. + /// + /// Must be paired with a prior call to `prepare_event_compat`. + /// Restores nonces and clears the snapshot. + pub fn finalize_event_compat(env: &Env) { + Self::restore_event_nonces(env); + } + + // ── PRIVATE: migration record persistence ──────────────────────────────── /// Append a migration record to the persisted migration history list.