diff --git a/EVENT_SCHEMA.md b/EVENT_SCHEMA.md index fde8b52..0530662 100644 --- a/EVENT_SCHEMA.md +++ b/EVENT_SCHEMA.md @@ -474,6 +474,108 @@ Emitted when the nominee accepts the admin role. --- +### `upgraded` + +Emitted by `upgrade()` after the WASM swap succeeds. Retained for backward +compatibility with indexers already parsing this event. + +| Index | Location | Type | Description | +|---------|----------|------------|----------------------------------------------------| +| topic 0 | topics | Symbol | `"upgraded"` | +| topic 1 | topics | Address | `caller` -- admin who executed the upgrade | +| data | data | BytesN<32> | `new_wasm_hash` -- hash of the deployed WASM blob | + +```json +{ + "topics": ["upgraded", "GADMIN..."], + "data": "a1b2c3d4e5f6..." +} +``` + +> **Deprecation note:** New indexers should prefer `upgrade_started` and +> `upgrade_completed` which carry structured payloads and support lifecycle +> correlation. `upgraded` will remain in every upgrade transaction for +> backward compatibility. + +--- + +### `upgrade_started` + +Emitted **before** `env.deployer().update_current_contract_wasm()` executes. +Carries the intended WASM hash and the previous version so indexers can +reconstruct full version history and detect failed upgrades (an `upgrade_started` +without a subsequent `upgrade_completed` in the same transaction indicates a +failure before the WASM swap completed). + +| Index | Location | Type | Description | +|-------------------|----------|--------------------|------------------------------------------------------------------| +| topic 0 | topics | Symbol | `"upgrade_started"` | +| topic 1 | topics | Address | `caller` -- admin who initiated the upgrade | +| `new_wasm_hash` | data | BytesN<32> | hash of the WASM intended to be installed | +| `previous_version`| data | Option> | hash from the last `upgrade()` call, or `None` on first upgrade | + +```json +{ + "topics": ["upgrade_started", "GADMIN..."], + "data": { + "new_wasm_hash": "b2c3d4e5f6a1...", + "previous_version": "a1b2c3d4e5f6..." + } +} +``` + +**First upgrade (no prior version):** + +```json +{ + "topics": ["upgrade_started", "GADMIN..."], + "data": { + "new_wasm_hash": "a1b2c3d4e5f6...", + "previous_version": null + } +} +``` + +**Indexer guidance.** +- `upgrade_started` and `upgrade_completed` share the same `new_wasm_hash` + value. Correlate them by matching that value within the same transaction. +- Track `previous_version` across consecutive upgrades to build a complete + on-chain version timeline. + +--- + +### `upgrade_completed` + +Emitted immediately **after** `env.deployer().update_current_contract_wasm()` +returns successfully, confirming the WASM swap took effect. + +| Index | Location | Type | Description | +|-----------------|----------|------------|-------------------------------------------| +| topic 0 | topics | Symbol | `"upgrade_completed"` | +| topic 1 | topics | Address | `caller` -- admin who executed the upgrade| +| `new_wasm_hash` | data | BytesN<32> | hash of the WASM that was installed | + +```json +{ + "topics": ["upgrade_completed", "GADMIN..."], + "data": { + "new_wasm_hash": "b2c3d4e5f6a1..." + } +} +``` + +**Indexer guidance.** +- `upgrade_completed` is always preceded by `upgrade_started` and `upgraded` + in the same transaction when upgrade succeeds. +- The emitted order within a successful `upgrade()` call is: + 1. `upgrade_started` (pre-swap) + 2. `upgrade_completed` (post-swap) + 3. `upgraded` (backward-compat) +- Absence of `upgrade_completed` after `upgrade_started` means the WASM swap + was rejected by the host (bad hash, auth failure, or host error). + +--- + ## Contract: `callora-revenue-pool` (v0.0.1) The revenue pool receives USDC forwarded by the vault on every `deduct` / `batch_deduct` @@ -920,6 +1022,9 @@ operational edge cases (off-chain payment reconciliation, dispute resolution). | `metadata_updated` | vault | `update_metadata()` | | `metadata_removed` | vault | `remove_metadata()` | | `distribute` | vault | `distribute()` | +| `upgraded` | vault | `upgrade()` -- backward-compat | +| `upgrade_started` | vault | `upgrade()` -- emitted before WASM swap | +| `upgrade_completed` | vault | `upgrade()` -- emitted after WASM swap | | `init` | revenue-pool | `init()` | | `admin_changed` | revenue-pool | `set_admin()` | | `admin_transfer_started` | revenue-pool | `set_admin()` | @@ -946,3 +1051,4 @@ operational edge cases (off-chain payment reconciliation, dispute resolution). | 0.0.1 | revenue-pool | Added `admin_changed` event on `set_admin` for explicit old/new admin intent | | 0.1.0 | settlement | `payment_received`, `balance_credited` | | 0.1.0 | settlement | `developer_force_credited` (admin escape hatch) | +| 0.2.0 | vault | `upgraded` (documented), `upgrade_started`, `upgrade_completed` -- lifecycle events (Issue #528) | diff --git a/contracts/settlement/src/lib.rs b/contracts/settlement/src/lib.rs index 86ad38d..0e79821 100644 --- a/contracts/settlement/src/lib.rs +++ b/contracts/settlement/src/lib.rs @@ -1,6 +1,6 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, token, Address, BytesN, Env, Symbol, Vec}; +use soroban_sdk::{contract, contractimpl, contracttype, token, Address, BytesN, Env, String, Symbol, Vec}; mod errors; pub use errors::SettlementError; @@ -11,6 +11,26 @@ pub const MAX_BATCH_SIZE: u32 = 50; /// Maximum number of developer balances returned per page in paginated queries. pub const MAX_DEVELOPER_BALANCES_PAGE_SIZE: u32 = 100; +/// Maximum length in bytes for admin broadcast messages. +pub const MAX_MESSAGE_LEN: u32 = 256; + +/// Severity level for admin broadcast messages. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Severity { + Info, + Warn, + Crit, +} + +/// Payload for admin broadcast events. +#[contracttype] +#[derive(Clone, Debug)] +pub struct AdminBroadcast { + pub severity: Severity, + pub message: String, +} + /// Persistent storage keys for settlement contract #[contracttype] #[derive(Clone, Debug, PartialEq)] diff --git a/contracts/vault/EVENT_SCHEMA.md b/contracts/vault/EVENT_SCHEMA.md new file mode 100644 index 0000000..c83fad5 --- /dev/null +++ b/contracts/vault/EVENT_SCHEMA.md @@ -0,0 +1,101 @@ +# Vault Upgrade Event Schema + +This document describes the three events emitted by `upgrade()` in the Callora Vault contract, +their topic layout, data payloads, and guidance for indexers. + +--- + +## Events emitted (in order, within the same transaction) + +| # | Topic 0 | Topic 1 | Data type | Emitted when | +|---|---------|---------|-----------|--------------| +| 1 | `upgrade_started` | admin `Address` | `UpgradeStartedData` | Before `env.deployer().update_current_contract_wasm()` | +| 2 | `upgrade_completed` | admin `Address` | `UpgradeCompletedData` | After the WASM swap succeeds | +| 3 | `upgraded` | admin `Address` | `BytesN<32>` (new hash) | After the version marker is persisted (backward-compat) | + +--- + +## `upgrade_started` + +**Purpose:** signals upgrade intent. An `upgrade_started` without a subsequent `upgrade_completed` +in the same transaction indicates the WASM swap was never executed (e.g., authorization failure +or WASM validation error). + +### Payload — `UpgradeStartedData` + +```rust +#[contracttype] +pub struct UpgradeStartedData { + pub new_wasm_hash: BytesN<32>, + pub previous_version: Option>, +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `new_wasm_hash` | `BytesN<32>` | WASM hash to be installed | +| `previous_version` | `Option>` | Hash from the previous upgrade, or `None` on first upgrade | + +### JSON example + +```json +{ + "topics": ["upgrade_started", ""], + "data": { + "new_wasm_hash": "aabbcc...3232", + "previous_version": null + } +} +``` + +--- + +## `upgrade_completed` + +**Purpose:** confirms the WASM swap succeeded. The `new_wasm_hash` matches the value carried +in the corresponding `upgrade_started` event from the same transaction. + +### Payload — `UpgradeCompletedData` + +```rust +#[contracttype] +pub struct UpgradeCompletedData { + pub new_wasm_hash: BytesN<32>, +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `new_wasm_hash` | `BytesN<32>` | WASM hash that was successfully installed | + +### JSON example + +```json +{ + "topics": ["upgrade_completed", ""], + "data": { + "new_wasm_hash": "aabbcc...3232" + } +} +``` + +--- + +## `upgraded` (backward compatibility) + +Retained from before this PR. Emitted after `upgrade_completed` with the same `new_wasm_hash` +as a bare `BytesN<32>` in the data position. Indexers already consuming `upgraded` require +no changes. + +--- + +## Indexer guidance + +- **Event ordering guarantee:** `upgrade_started` always precedes `upgrade_completed` which always + precedes `upgraded` within the same ledger close. +- **Detecting failed upgrades:** if `upgrade_started` appears without `upgrade_completed` in the + same transaction, the upgrade was initiated but the WASM swap did not complete. +- **Version chain reconstruction:** collect all `upgrade_started` events and follow + `previous_version` links to reconstruct the full upgrade history for a contract instance. +- **Deduplication:** correlate `upgrade_started` and `upgrade_completed` by matching + `new_wasm_hash` within the same transaction hash. diff --git a/contracts/vault/src/events.rs b/contracts/vault/src/events.rs index af42294..907e1ca 100644 --- a/contracts/vault/src/events.rs +++ b/contracts/vault/src/events.rs @@ -220,6 +220,24 @@ pub fn event_admin_broadcast(env: &Env) -> Symbol { Symbol::new(env, "admin_broadcast") } +/// Returns the Symbol for the `"upgrade_started"` event topic. +/// +/// Emitted before `env.deployer().update_current_contract_wasm()` executes, +/// recording the intended WASM hash and the previous version. Indexers can +/// use the absence of `upgrade_completed` in the same transaction to detect +/// failed upgrades. +pub fn event_upgrade_started(env: &Env) -> Symbol { + Symbol::new(env, "upgrade_started") +} + +/// Returns the Symbol for the `"upgrade_completed"` event topic. +/// +/// Emitted after `env.deployer().update_current_contract_wasm()` returns +/// successfully, confirming the WASM swap took effect. +pub fn event_upgrade_completed(env: &Env) -> Symbol { + Symbol::new(env, "upgrade_completed") +} + #[cfg(test)] mod tests { use super::*; @@ -436,4 +454,20 @@ mod tests { let sym = event_admin_broadcast(&env); assert_eq!(sym, Symbol::new(&env, "admin_broadcast")); } + + /// Snapshot: proves event_upgrade_started maps to exactly the bytes for "upgrade_started". + #[test] + fn test_event_upgrade_started_bytes() { + let env = soroban_sdk::Env::default(); + let sym = event_upgrade_started(&env); + assert_eq!(sym, Symbol::new(&env, "upgrade_started")); + } + + /// Snapshot: proves event_upgrade_completed maps to exactly the bytes for "upgrade_completed". + #[test] + fn test_event_upgrade_completed_bytes() { + let env = soroban_sdk::Env::default(); + let sym = event_upgrade_completed(&env); + assert_eq!(sym, Symbol::new(&env, "upgrade_completed")); + } } diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index 4684fb5..5109b2d 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -31,8 +31,8 @@ /// persistent, they do not silently archive. To prevent state bloat, an owner /// can explicitly prune old markers using `prune_processed_requests`. use soroban_sdk::{ - contract, contractclient, contractimpl, contracttype, token, Address, BytesN, Env, String, - Symbol, Vec, + contract, contractclient, contractimpl, contracterror, contracttype, token, Address, BytesN, + Env, String, Symbol, Vec, }; /// Typed error codes for the Callora Vault contract. @@ -1449,51 +1449,28 @@ impl CalloraVault { Ok(()) } - /// Admin-gated contract upgrade. - /// - /// Only the current admin may call. This will instruct the host to update - /// the current contract WASM to `new_wasm_hash` and persist the version marker. - /// - /// # Parameters - /// - `caller` — must be the vault admin; signature required. - /// - `new_wasm_hash` — 32-byte hash of the new WASM code to deploy. - /// - /// # Panics - /// - `"unauthorized: caller is not admin"` — `caller` is not the admin. - /// - /// # Events - /// Emits an `upgraded` event with the admin as topic and the new WASM hash as data. - /// - /// # Post-Upgrade Migration - /// After calling `upgrade`, you may need to invoke a separate `migrate` function - /// (if implemented in the new WASM) to update storage schema or perform data migrations. - /// See UPGRADE.md for the complete operational flow. - pub fn broadcast(env: Env, caller: Address, severity: Severity, message: String) -> Result<(), VaultError> { - caller.require_auth(); - let admin = Self::get_admin(env.clone())?; - if caller != admin { - return Err(VaultError::Unauthorized); - } - let len = message.len(); - if len == 0 { - panic!("message cannot be empty"); - } - if len > MAX_MESSAGE_LEN { - panic!("message length exceeds maximum of 256 characters"); - } - env.events().publish( - (events::event_admin_broadcast(&env), caller), - AdminBroadcast { severity, message }, - ); - Ok(()) - } - pub fn upgrade(env: Env, caller: Address, new_wasm_hash: BytesN<32>) { caller.require_auth(); let admin = Self::get_admin(env.clone()).expect("vault must be initialized before upgrade"); + // Read the previous version before the swap so we can include it in the + // upgrade_started payload. None on the first upgrade, Some on subsequent ones. + let previous_version: Option> = env + .storage() + .instance() + .get(&StorageKey::ContractVersion); + + // Emit upgrade_started BEFORE the WASM swap so indexers can detect + // upgrades that were initiated but failed (no matching upgrade_completed). + env.events().publish( + (events::event_upgrade_started(&env), admin.clone()), + upgrade::UpgradeStartedData { + new_wasm_hash: new_wasm_hash.clone(), + previous_version, + }, + ); + // Perform the on-chain upgrade via the deployer interface. - // This is a host operation and may only succeed in the live environment. env.deployer() .update_current_contract_wasm(new_wasm_hash.clone()); @@ -1502,7 +1479,16 @@ impl CalloraVault { .instance() .set(&StorageKey::ContractVersion, &new_wasm_hash); - // Emit an event for indexers / audit logs. + // Emit upgrade_completed to confirm the WASM swap succeeded. + env.events().publish( + (events::event_upgrade_completed(&env), admin.clone()), + upgrade::UpgradeCompletedData { + new_wasm_hash: new_wasm_hash.clone(), + }, + ); + + // Retain the original `upgraded` event for backward compatibility with + // indexers that already parse it. env.events() .publish((events::event_upgraded(&env), admin), new_wasm_hash); } @@ -1688,6 +1674,7 @@ impl CalloraVault { } mod events; +mod upgrade; // --------------------------------------------------------------------------- // Test modules diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index 120c1e7..40fdfc9 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -5895,6 +5895,247 @@ fn upgrade_multiple_times_updates_version() { assert_eq!(client.get_version(), Some(hash3)); } +// --------------------------------------------------------------------------- +// Upgrade lifecycle event tests (Issue #528) +// --------------------------------------------------------------------------- + +/// Verifies that a single upgrade call emits all three expected events: +/// upgrade_started, upgrade_completed, and upgraded (backward-compat). +#[test] +fn upgrade_emits_all_three_lifecycle_events() { + let env = Env::default(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 100); + client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); + + let new_hash = BytesN::from_array(&env, &[20u8; 32]); + client.upgrade(&owner, &new_hash); + + let all = env.events().all(); + let vault_events: Vec<_> = all.iter() + .filter(|ev| ev.0 == vault_address) + .collect(); + + let find_event = |name: &str| -> bool { + vault_events.iter().any(|ev| { + ev.1.get(0) + .map(|v| v.into_val::(&env) == Symbol::new(&env, name)) + .unwrap_or(false) + }) + }; + + assert!(find_event("upgrade_started"), "upgrade_started event not emitted"); + assert!(find_event("upgrade_completed"), "upgrade_completed event not emitted"); + assert!(find_event("upgraded"), "upgraded event not emitted (backward-compat)"); +} + +/// Verifies that upgrade_started is emitted before upgrade_completed. +/// +/// This ordering guarantee lets indexers detect incomplete upgrades: if +/// upgrade_started appears without upgrade_completed in the same transaction, +/// the WASM swap failed. +#[test] +fn upgrade_started_precedes_upgrade_completed() { + let env = Env::default(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 100); + client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); + + let new_hash = BytesN::from_array(&env, &[21u8; 32]); + client.upgrade(&owner, &new_hash); + + let all = env.events().all(); + let vault_events: Vec<_> = all.iter() + .filter(|ev| ev.0 == vault_address) + .collect(); + + let started_idx = vault_events.iter().position(|ev| { + ev.1.get(0) + .map(|v| v.into_val::(&env) == Symbol::new(&env, "upgrade_started")) + .unwrap_or(false) + }); + let completed_idx = vault_events.iter().position(|ev| { + ev.1.get(0) + .map(|v| v.into_val::(&env) == Symbol::new(&env, "upgrade_completed")) + .unwrap_or(false) + }); + let upgraded_idx = vault_events.iter().position(|ev| { + ev.1.get(0) + .map(|v| v.into_val::(&env) == Symbol::new(&env, "upgraded")) + .unwrap_or(false) + }); + + assert!(started_idx.is_some(), "upgrade_started not found"); + assert!(completed_idx.is_some(), "upgrade_completed not found"); + assert!(upgraded_idx.is_some(), "upgraded not found"); + assert!( + started_idx.unwrap() < completed_idx.unwrap(), + "upgrade_started must precede upgrade_completed" + ); + assert!( + completed_idx.unwrap() < upgraded_idx.unwrap(), + "upgrade_completed must precede upgraded" + ); +} + +/// Verifies that upgrade_started carries the caller's address as topic 1. +#[test] +fn upgrade_started_topic_includes_caller() { + let env = Env::default(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 100); + client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); + + let new_hash = BytesN::from_array(&env, &[22u8; 32]); + client.upgrade(&owner, &new_hash); + + let all = env.events().all(); + let started = all.iter().find(|ev| { + ev.0 == vault_address + && ev.1.get(0) + .map(|v| v.into_val::(&env) == Symbol::new(&env, "upgrade_started")) + .unwrap_or(false) + }); + + let started = started.expect("upgrade_started not emitted"); + let caller_topic: Address = started.1.get(1).unwrap().into_val(&env); + assert_eq!(caller_topic, owner, "upgrade_started topic 1 should be the caller"); +} + +/// Verifies that upgrade_completed carries the caller's address as topic 1. +#[test] +fn upgrade_completed_topic_includes_caller() { + let env = Env::default(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 100); + client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); + + let new_hash = BytesN::from_array(&env, &[23u8; 32]); + client.upgrade(&owner, &new_hash); + + let all = env.events().all(); + let completed = all.iter().find(|ev| { + ev.0 == vault_address + && ev.1.get(0) + .map(|v| v.into_val::(&env) == Symbol::new(&env, "upgrade_completed")) + .unwrap_or(false) + }); + + let completed = completed.expect("upgrade_completed not emitted"); + let caller_topic: Address = completed.1.get(1).unwrap().into_val(&env); + assert_eq!(caller_topic, owner, "upgrade_completed topic 1 should be the caller"); +} + +/// Verifies that upgrade_started carries UpgradeStartedData with the new hash +/// and None previous_version on the first upgrade. +#[test] +fn upgrade_started_data_first_upgrade_has_no_previous_version() { + let env = Env::default(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 100); + client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); + + let new_hash = BytesN::from_array(&env, &[24u8; 32]); + client.upgrade(&owner, &new_hash); + + let all = env.events().all(); + let started = all.iter().find(|ev| { + ev.0 == vault_address + && ev.1.get(0) + .map(|v| v.into_val::(&env) == Symbol::new(&env, "upgrade_started")) + .unwrap_or(false) + }); + + let started = started.expect("upgrade_started not emitted"); + let data: super::upgrade::UpgradeStartedData = started.2.into_val(&env); + assert_eq!(data.new_wasm_hash, new_hash, "UpgradeStartedData.new_wasm_hash mismatch"); + assert_eq!(data.previous_version, None, "first upgrade should have no previous version"); +} + +/// Verifies that a second upgrade's upgrade_started carries the first hash as previous_version. +#[test] +fn upgrade_started_data_second_upgrade_has_previous_version() { + let env = Env::default(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 100); + client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); + + let hash1 = BytesN::from_array(&env, &[25u8; 32]); + client.upgrade(&owner, &hash1); + + let hash2 = BytesN::from_array(&env, &[26u8; 32]); + client.upgrade(&owner, &hash2); + + let all = env.events().all(); + // Collect all upgrade_started events in order; the second one corresponds to hash2. + let started_events: Vec<_> = all.iter().filter(|ev| { + ev.0 == vault_address + && ev.1.get(0) + .map(|v| v.into_val::(&env) == Symbol::new(&env, "upgrade_started")) + .unwrap_or(false) + }).collect(); + + assert_eq!(started_events.len(), 2, "expected 2 upgrade_started events"); + + // Second upgrade_started should have hash1 as previous_version. + let second_data: super::upgrade::UpgradeStartedData = started_events[1].2.into_val(&env); + assert_eq!(second_data.new_wasm_hash, hash2); + assert_eq!(second_data.previous_version, Some(hash1), + "second upgrade should record first hash as previous_version"); +} + +/// Verifies that upgrade_completed carries UpgradeCompletedData with the correct hash. +#[test] +fn upgrade_completed_data_contains_new_hash() { + let env = Env::default(); + let owner = Address::generate(&env); + let (vault_address, client) = create_vault(&env); + let (usdc, _, usdc_admin) = create_usdc(&env, &owner); + + env.mock_all_auths(); + fund_vault(&usdc_admin, &vault_address, 100); + client.init(&owner, &usdc, &Some(100), &None, &None, &None, &None); + + let new_hash = BytesN::from_array(&env, &[27u8; 32]); + client.upgrade(&owner, &new_hash); + + let all = env.events().all(); + let completed = all.iter().find(|ev| { + ev.0 == vault_address + && ev.1.get(0) + .map(|v| v.into_val::(&env) == Symbol::new(&env, "upgrade_completed")) + .unwrap_or(false) + }); + + let completed = completed.expect("upgrade_completed not emitted"); + let data: super::upgrade::UpgradeCompletedData = completed.2.into_val(&env); + assert_eq!(data.new_wasm_hash, new_hash, "UpgradeCompletedData.new_wasm_hash mismatch"); +} + // --------------------------------------------------------------------------- // BUDGET MEASUREMENT TESTS — for benchmarking and cost analysis // --------------------------------------------------------------------------- diff --git a/contracts/vault/src/upgrade.rs b/contracts/vault/src/upgrade.rs new file mode 100644 index 0000000..56ac480 --- /dev/null +++ b/contracts/vault/src/upgrade.rs @@ -0,0 +1,38 @@ +//! Structured event payloads for vault upgrade lifecycle events. +//! +//! Splitting these into a dedicated module keeps `lib.rs` focused on business +//! logic and lets indexers import only the types they need. + +use soroban_sdk::{contracttype, BytesN}; + +/// Payload for the `upgrade_started` event. +/// +/// Emitted **before** `env.deployer().update_current_contract_wasm()` executes. +/// An indexer that observes `upgrade_started` without a subsequent +/// `upgrade_completed` in the same transaction can conclude the upgrade failed +/// (e.g., insufficient authorization or WASM validation error). +/// +/// `previous_version` is `None` on the first ever upgrade; `Some` on all +/// subsequent upgrades, allowing full version history reconstruction. +#[contracttype] +#[derive(Clone, Debug)] +pub struct UpgradeStartedData { + /// The WASM hash that will be installed if the upgrade succeeds. + pub new_wasm_hash: BytesN<32>, + /// The WASM hash stored from the previous upgrade, or `None` if this is + /// the first upgrade of this contract instance. + pub previous_version: Option>, +} + +/// Payload for the `upgrade_completed` event. +/// +/// Emitted immediately **after** `env.deployer().update_current_contract_wasm()` +/// returns successfully. The `new_wasm_hash` matches the value from the +/// corresponding `upgrade_started` payload emitted earlier in the same +/// transaction, allowing indexers to correlate the two events. +#[contracttype] +#[derive(Clone, Debug)] +pub struct UpgradeCompletedData { + /// The WASM hash that was successfully installed. + pub new_wasm_hash: BytesN<32>, +}